27. Express – własny serwer HTTP

Wyzwania:

  • zbudujesz swój pierwszy serwer,
  • nauczysz się jak testować endpointy bez tworzenia warstwy klienta,
  • dowiesz się jak wykorzystać system szablonów handlebars również na serwerze,
  • opublikujesz swoją pierwszą fullstackową aplikację.

Wstęp

Większość kursów i tutoriali wprowadzających do Node.js skupia się tylko i wyłącznie na jego jednej zalecie – możliwości tworzenia serwerów. Oczywiście jest to prawdopodobnie najbardziej istotna cecha tego narzędzia, ale – jak wiesz już po lekturze poprzedniego modułu – niejedyna. Tak czy inaczej, nie mieliśmy jeszcze okazji bezpośrednio zmierzyć się z tą funkcjonalnością. Czas to zmienić!

Nie będziemy jednak pracować w czystym Node.js. Jest to możliwe, ale na dłuższą metę, szczególnie przy większych projektach, trochę niewygodne. Musielibyśmy pamiętać o zbyt wielu aspektach, które trzeba wziąć pod uwagę. Zamiast tego skorzystamy z frameworka o nazwie Express, który pozwoli osiągnąć takie same efekty, ale przejmie od nas część obowiązków.

27.1. Wprowadzenie do Expressa

Express to szybki i wydajny framework do Node'a. Jego zadaniem jest przede wszystkim pomoc przy tworzeniu serwerów, obsłudze endpointów, a także wsparcie w zakresie bezpieczeństwa aplikacji webowych. Krótko mówiąc, Express pozwala nam tworzyć serwery oraz całe aplikacje webowe w łatwy i szybki sposób, przy okazji przejmując od nas wiele zadań. Jego największymi zaletami są: minimalizm, intuicyjność i łatwość nauki.

Oczywiście Express to tylko framework, a nie zupełnie nowe narzędzie. Tak naprawdę pod maską korzysta z czystego Node'a. Wszystko, co oferuje, możemy więc uzyskać również bez jego pomocy, ale byłoby to bardziej pracochłonne.

Co jest sporym plusem – twórcy frameworka skupili się na funkcjonalnościach webowych i właśnie do takich zastosowań Express nam się przyda. W przypadku aplikacji, które wykonywaliśmy np. w poprzednim module, byłby zupełnie zbędny.

Express nie jest jedynym frameworkiem tego typu. Znane są również np. Koa.js czy Feather.js, które oferują zbliżone funkcjonalności. Dlaczego wybraliśmy właśnie jego? Przede wszystkim z powodu popularności – to zdecydowanie numer 1 na rynku. Do tego jest obecny w web developmencie już od bardzo dawna, możemy być pewni jego stabilności i że nagle nie zniknie. Obie te zalety powodują, że cieszy się on sporym zainteresowaniem wśród pracodawców, a w internecie znajdziesz wiele pomocnych materiałów.

Frontend vs backend

Zanim przejdziemy do praktyki, wyjaśnijmy czym w ogóle jest serwer.

Przypomnijmy sobie. Serwer to, najprościej mówiąc, jakiś komputer udostępniający użytkownikowi lub użytkownikom (klientom) swoje zasoby. Zasobami mogą być po prostu pliki, bazy danych, albo np. strony internetowe.

image

Serwer może mieć bardzo różne zastosowania. Istnieją serwery FTP (serwery plików), poczty, telnet (zdalna obsługa komputera), czy WWW. Nas interesuje właśnie ten ostatni, który pozwala na serwowanie stron internetowych oraz prostą komunikację za pomocą protokołu HTTP.

Serwer WWW jest wykorzystywany między innymi przez Webpacka do tworzenia podglądu Twojej strony. Podobnie działa też wtyczka Live Server w Visual Studio Code, a bezpośrednią styczność z tą technologią mieliśmy już w module wprowadzającym do AJAX-a i API. Wtedy w praktyce dowiedzieliśmy się, jak wygląda praca z serwerem i nawet się z nim komunikowaliśmy.

Idea serwera nie jest więc dla Ciebie żadną nowością, choć dotychczas było Ci dane poznać ją od jednej strony – klienta. Teraz przyjrzymy się tematowi z trochę innej perspektywy ;)

Cienka granica

Podział pomiędzy frontedenem i backendem jest już od dłuższego czasu bardzo rozmyty. Wcześniej ta granica była dość mocno zarysowana. Backend zajmował się całą logiką aplikacji oraz kontaktem z bazą danych, a frontend obsługiwał tylko komunikację z użytkownikiem. Wraz z rozwojem JS-a, sytuacja uległa zmianie. Na tym etapie kursu wiesz, że nawet bez wykorzystania serwera jesteśmy w stanie zbudować całkiem skomplikowane aplikacje. Faktycznie JS jest teraz o wiele bardziej funkcjonalny, co wpływa na zmianę balansu – coraz częściej frontend zajmuję się większością logiki aplikacji, a backend pełni drugorzędną funkcję. Bardzo często sprowadza się ona jedynie do obsługi bazy danych. W takiej sytuacji mówimy o serwerach API.

Pamiętaj jednak, że nie zawsze musi tak być. Wciąż istnieje mnóstwo aplikacji i stron, w których główną rolę odgrywa serwer. Tak działają np. wszystkie blogi oparte na popularnym systemie Wordpress.

Node.js – a może coś innego?

Czy istnieje jakaś alternatywa dla Node'a, jeśli chodzi o tworzenie serwerów? Oczywiście, nawet kilka. Jedną z najbardziej popularnych jest oprogramowanie Apache, pozwalające na pisanie backendu w języku PHP. To rozwiązanie o wiele starsze niż Node.js, jednak wciąż mocno zakorzenione w rynku.

Zatem dlaczego używamy właśnie Node'a?

Uruchomienie JS-a na serwerze Apache byłoby karkołomnym zadaniem, a sama nauka nowego języka zapewne jeszcze bardziej wymagająca. Współpraca frontendu JS z backendem PHP jest możliwa, ale dość kłopotliwa, bowiem oba języki preferują inne formaty danych.

W tym momencie widzimy jedną z głównych zalet Node.js – pozwala na pisanie frontendu i backendu w tym samym języku. Dzięki temu, o wiele łatwiej możemy zintegrować całą aplikację oraz tworzyć ją od początku do końca samemu, bez potrzeby uczenia się dodatkowego języka programowania.

Pierwsze kroki

Zacznij od stworzenia nowego katalogu na nasz projekt. Następnie pobierz express i utwórz plik package.json.

yarn add express@4.17.1

Dodaj nowy plik – server.js. To właśnie on, po skompilowaniu, będzie uruchamiał na naszym komputerze serwer. Następnie wstaw do niego następującą treść:

const express = require('express');

const app = express();

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Teraz po kolei omówmy ten kod.

const express = require('express');

Tutaj komentarz jest zbędny. Po prostu importujemy zewnętrzną paczkę do naszej aplikacji. Po module o Node.js nie powinno to być dla Ciebie nowością.

const app = express();

W tym momencie tworzymy nową aplikację expressową i przypisujemy ją do stałej app. Po co? Żeby później mieć do niej dostęp.

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Kolejna część kodu mówi po prostu, na jakim porcie chcemy utworzyć serwer HTTP. W naszym komputerze może być odpalonych wiele serwerów (chociażby webpack-dev-server) i aby możliwa była ich identyfikacja, wykorzystujemy właśnie mechanizm portów.

Jeśli u Ciebie port 8000 jest już zajęty, możesz ustawić tu inną wartość, np. 3000 albo 9000. Przykładowo Create React App, oficjalny szablon Reacta, wykorzystuje port 3000 do uruchamiania podglądu aplikacji.

() => {
  console.log('Server is running on port: 8000');
}

Co do samej funkcji callback – nie jest wymagana, ale możemy jej użyć np. do wyświetlenie komunikatu po utworzeniu serwera.

Czy taki krótki kod jest w stanie utworzyć nasz serwer? Tak! Oczywiście "pod maską" dzieje się o wiele więcej rzeczy, ale w tym momencie one nas nie interesują.

Pierwsze uruchomienie

Czas sprawdzić, czy wszystko działa! Uruchom terminal i odpal w swoim folderze komendę node server.js. Naturalne wciąż używamy Node'a do kompilacji. Nie zapomnij – Express wykorzystuje właśnie funkcjonalności Node.js.

node server.js

Następnie wejdź do przeglądarki i odwiedź localhost:8000.

localhost?

localhost oznacza, że szukamy serwera lokalnego, dostępnego na naszym komputerze. Kiedy chcemy za pomocą przeglądarki łączyć się z innymi serwerami (komputerami) dostępnymi w internecie, ale poza naszym komputerem, musimy wpisać ich adres IP (np. 52.19.39.113 dla serwera kodilla.com) albo, co częściej czynimy, adres domenowy (np. kodilla.com) – o ile serwer taki posiada.

Przed Twoimi oczami powinien pokazać się następujący tekst:

Cannot GET /

Informuje nas to o dwóch rzeczach. Po pierwsze serwer faktycznie działa (brawo!), a po drugie, nie wie, co ma pokazać zaraz po wejściu. Komunikat Cannot GET / zwyczajnie mówi nam: nie mogę znaleźć żadnego endpointu pod linkiem / dla metody GET i nie wiem co pokazać. To całkiem sensowne, bo faktycznie nie przygotowaliśmy jeszcze żadnego endpointu. Zaraz to zrobimy.

Dlaczego jednak serwer szukał endpointu /? Przecież w linku http://localhost:8000 nie widać żadnego ukośnika na końcu. I dlaczego szukał endpointu pod metodę GET?

Sprawę linku możemy wytłumaczyć bardzo prosto. Ścieżka / jest po prostu główną, wybieraną wtedy, kiedy wpiszemy tylko sam adres serwera. Musimy ją opisać za pomocą chociażby jednego znaku – stąd używane oznaczenie /.

A dlaczego GET? Przypomnijmy jakie metody były przez Ciebie używane:

  • GET – do pobierania danych.
  • POST – do dodawania danych.
  • PUT – do modyfikacji danych.
  • DELETE – do usuwania danych.

Która Twoim zdaniem najbardziej pasuje do procesu wejścia na stronę? Tak jest, właśnie GET. W końcu wchodząc na jakąś witrynę, my tak naprawdę pobieramy daną stronę – kod HTML, pliki JS, używane arkusze CSS itd. Właśnie dlatego jest to domyślna metoda używana przez przeglądarki.

Pierwszy endpoint

Wiemy już, jaki jest problem, teraz trzeba go rozwiązać. Czas na dodanie pierwszego endpointu.

Czym jest endpoint? To po prostu adres serwera, pod którym będzie on wykonywał jakąś konkretną czynność. Na przykład endpoint posts/1 może zwracać post o id 1, a endpoint posts/all wszystkie posty.

Przejdźmy teraz do pracy. Zmodyfikuj plik server.js dodając do niego endpoint pod link / oraz zakładając, że chodzi o połączenie za pomocą metody GET. Musimy ją wskazać, bowiem istnieje możliwość tworzenia kilku endpointów dla tego samego linku, ale innych metod. Wtedy ten sam link, ale przy połączeniu za pomocą innej metody, może zwrócić zupełnie inny rezultat. Np. posts/all z metodą GET może służyć do pobierania wszystkich postów, a posts/all z metodą DELETE do ich usuwania.

const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.send('<h1>My first server!</h1>');
});

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Musisz przyznać, że sposób dodawania endpointów jest dość intuicyjny – app to po prostu odnośnik do naszego serwera, a .get ustala metodę, którą chcemy obsługiwać w tym endpoincie (oczywiście dla POST byłoby to .post itd.). Pierwszy parametr tej funkcji ustala już konkretnie link, o którym mowa, a drugi to callback – funkcja, która ma uruchomić się w sytuacji, gdy serwer wykryje, że użytkownik wchodzi właśnie pod ten link.

Parametry req, res dają nam dostęp do obiektu zapytania/żądania (ang. request) i odpowiedzi (ang. response). Co w nich jest?

  • req zawiera informacje o użytkowniku, który łączy się z serwerem (jego przeglądarka, IP itp.) oraz o samym zapytaniu. Ma więc np. dostęp do danych wysyłanych wraz z zapytaniem (o ile użytkownik takie wysyłał).

  • res zawiera za to wiele przydatnych metod do komunikacji zwrotnej. Możemy wysłać np. komunikat tekstowy (metoda send), kod HTML (również send), dane w formacie JSON (metoda json) czy... nawet cały plik (metoda sendFile)! Istnieją tutaj również np. metody do zwracania kodów statusu. Pamiętasz 200, 404 albo spędzający sen z powiek 500? Teraz sami możemy zdecydować, kiedy "wyrzucimy" użytkownikowi błąd, a kiedy powiemy, że wszystko poszło dobrze.

Oczywiście oba obiekty są na tyle obszerne, że nie będziemy ich szczegółowo opisywać – od tego jest dokumentacja. W niniejszym module poznasz jednak ich wszystkie najistotniejsze możliwości.

A może inne nazwy?

Czy moglibyśmy użyć innych nazw zamiast req i res? Oczywiście. Pamiętasz nasłuchiwacze? Pierwszy parametr w funkcjach nazywaliśmy e albo event. Tutaj także nazwy mogą być inne, choć pierwszy parametr zawsze będzie odwoływał się do obiektu zapytania, a drugi do odpowiedzi. Przyjęło się jednak, że będą one nazywane req i res, dlatego i my będziemy się tej konwencji trzymać, podobnie jak trzymaliśmy się nazw event lub e przy nasłuchiwaczach.

Teraz uruchom ponownie nasz serwer i sprawdź rezultat.

image

Nie jest to może coś imponującego, ale oznacza, że serwer dobrze obsłużył żądanie.

Kolejne endpointy

W ramach praktyki dodamy sobie jeszcze kolejne endpointy.

Oto nasze założenia:

  • /about pod metodą GET – powinna zwracać tekst <h1>About</h1>,
  • /contact pod metodą GET – powinna zwracać tekst <h1>Contact</h1>,
  • /info pod metodą GET – powinna zwracać tekst <h1>Info</h1>,
  • /history pod metodą GET – powinna zwracać tekst <h1>History</h1>.

Zacznijmy od dodania dwóch pierwszych:

app.get('/about', (req, res) => {
  res.send('<h1>About</h1>');
});

app.get('/contact', (req, res) => {
  res.send('<h1>Contact</h1>');
});

Musisz przyznać, że to nie było takie trudne. Express faktycznie jest bardzo intuicyjny, a zarazem czytelny. Powyższy kod oczywiście działa dość prosto. Jeśli wejdziemy na link /about za pomocą metody GET (dla przypomnienia, to metoda, której przeglądarka używa domyślnie), otrzymamy komunikat About. Jeśli odwiedzimy /contact za pomocą tej samej metody, serwer zwróci tekst Contact.

W ramach treningu spróbuj dodać pozostałe dwa endpointy na własną rękę. W efekcie powinniśmy otrzymać serwer, który działa następująco:

image

const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.send('<h1>My first server!</h1>');
});

app.get('/about', (req, res) => {
  res.send('<h1>About</h1>');
});

app.get('/contact', (req, res) => {
  res.send('<h1>Contact</h1>');
});

app.get('/info', (req, res) => {
  res.send('<h1>Info</h1>');
});

app.get('/history', (req, res) => {
  res.send('<h1>History</h1>');
});

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Swoją drogą, czy kod naszej aplikacji coś Ci przypomina? Zastosowaliśmy tu zwykły routing, który znasz już z kursu, choć tym razem był to routing po stronie serwera. Zamysł jest jednak analogiczny – pod danym linkiem pokazujemy konkretną treść.

Czy naprawdę Express jest nam potrzebny?

Możliwe, że wciąż zadajesz sobie jedno pytanie – jak wyglądałby nasz kod bez użycia Expressu?

const http = require('http');

const server = http.createServer((req, res) => {

  if(req.url === '/' && req.method === 'GET') {
    res.write('<h1>My first server!</h1>');
    res.end();
  }
  else if(req.url === 'about/' && req.method === 'GET') {
    res.write('<h1>About</h1>');
    res.end();
  }

  // ... then similar other routes

})

server.listen(8000, (err) => {
  if (err) {
    return console.log('something bad happened', err);
  }

  console.log(`server is listening on ${8000}`);
});

Czy jest o wiele gorzej? To wciąż w miarę czytelny kod, choć zapewne widzisz sporą różnicę na korzyść Expressu.

Podsumowanie

Express jest stosunkowo przyjazny w obsłudze. Nasza mała aplikacja działa naprawdę dobrze, a sam kod jest przejrzysty. To jedne z zalet tego frameworka – łatwość w obsłudze i czytelność.

27.2. Serwowanie plików statycznych i middleware

Nasza aplikacja z pierwszego modułu była niezwykle prymitywna, więc teraz trochę ją rozwiniemy. Przy okazji poznamy kolejne możliwości oferowane przez Express.

Serwowanie plików statycznych

Zacznijmy od rozwinięcia treści naszych stron. Sam nagłówek to raczej za mało, prawda? Dodamy więc trochę tekstu do odpowiedzi każdego z endpointów.

W tym miejscu jednak od razu w głowie zapala się czerwona lampka. Dopóki zwracaliśmy sam nagłówek, spokojnie mogliśmy go trzymać bezpośrednio w pliku server.js. Przechowywanie tam większej treści nie ma jednak większego sensu – skrypt niepotrzebnie by się rozrósł, edycja plików stałaby się koszmarem, a ponadto każda ze stron powinna mieć przecież całą semantyczną strukturę.

Na szczęście Express zadbał o to, by nam pomóc. Treść HTML możemy trzymać w zewnętrznych plikach, a następnie zwrócić je pod danym endpointem za pomocą przyjaznej metody sendFile.

Jej użycie jest następujące:

app.get('link', (req, res) => {
  res.sendFile('filename-path');
});

To zaskakująco proste, prawda?

Rozwijamy podstrony

Czas wykorzystać nową wiedzę w praktyce!

Zacznij od stworzenia katalogu views – to tam będziemy trzymać nasze pliki HTML. Oczywiście nazwa folderu jest dowolna. To, gdzie Express będzie szukał danego pliku, zależy po prostu od ścieżki, którą wskażemy.

mkdir views

Następnie utwórz pięć nowych plików, po jednym dla każdego endpointu.

touch views/index.html views/about.html views/contact.html views/info.html views/history.html

Teraz wypełnij je następującą treścią:

index.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Home</title>
  </head>
  <body>
    <h1>Home</h1>
    <p>Welcome to my page!</p>
  </body>
</html>

about.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>About</title>
  </head>
  <body>
    <h1>About</h1>
    <p>I'm a very skillful programmer. Nice to meet you.</p>
  </body>
</html>

contact.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Contact</title>
  </head>
  <body>
    <h1>Contact</h1>
    <p>Send me an email at programmer@example.com</p>
  </body>
</html>

info.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Info</title>
  </head>
  <body>
    <h1>Info</h1>
    <p>I will put here all my accomplishments. Stay tuned!</p>
  </body>
</html>

history.html

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>History</title>
  </head>
  <body>
    <h1>History</h1>
    <p>Page not yet available... Give me more time, folks.</p>
  </body>
</html>

Nadszedł czas na odpowiednie zmiany w server.js. Nie chcemy już zwracać samych nagłówków, teraz będą to całe pliki!

const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.sendFile('./views/index.html');
});

app.get('/about', (req, res) => {
  res.sendFile('./views/about.html');
});

app.get('/contact', (req, res) => {
  res.sendFile('./views/contact.html');
});

app.get('/info', (req, res) => {
  res.sendFile('./views/info.html');
});

app.get('/history', (req, res) => {
  res.sendFile('./views/history.html');
});

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

I... nie działa... Serwer, zamiast treści zaczął wyrzucać błąd – path must be absolute or specify root to res.sendFile. O co chodzi?

To, że serwer został uruchomiony w Twoim katalogu projektu, wcale nie oznacza, że chcesz, aby szukał plików właśnie tam. Express wymaga od nas ustalenia dokładnej ścieżki do pliku. Co mamy tutaj na myśli? Jeśli Twój folder projektu to np. C:\Kodilla\Express, to dokładną ścieżką do index.html byłoby C:\Kodilla\Express\views\index.html, a dla about.htmlC:\Kodilla\Express\views\about.html.

Czy to oznacza, że musimy wpisywać aż tak długie ścieżki? Przecież nie byłoby to dobrym pomysłem. Zwykła zmiana lokalizacji naszego projektu powodowałaby potrzebę modyfikacji każdego z linków... Po raz kolejny Express nie zostawia nas bez pomocy i proponuje użycie wbudowanego w Node'a modułu path. Pamiętasz go? Był już przez nas używany.

const express = require('express');
const path = require('path');

const app = express();

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/index.html'));
});

...

Jak to działa?

path.join(__dirname, '/views/index.html')

Metoda path.join stara się odpowiednio "skleić" ścieżkę bazową ze ścieżką docelową. Np. path.join('C:/Kodilla/Test' + '/views/index.html) powinno dać nam w rezultacie C:/Kodilla/Test/views/index.html. Natomiast stała __dirname zwraca adres aktualnej ścieżki. Dzięki niej nie będziemy musieli wpisywać, że skrypt aktualnie znajduje się np. w lokalizacji C:\Kodilla\Test, tylko ustali to za każdym razem sam Node.js. W takiej sytuacji zmiana lokalizacji projektu nie będzie nam straszna. Node po prostu zwróci pod __dirname nową ścieżkę i skrypt wciąż będzie działał.

Powyższy kod możemy więc rozumieć jako rozkaz: znajdź plik index.html w folderze views, przy czym zacznij szukać w katalogu, w którym odpalono skrypt.

Wyposażeni w nową wiedzę możemy zmodyfikować nasz kod.

const express = require('express');
const path = require('path');

const app = express();

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/index.html'));
});

app.get('/about', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/about.html'));
});

app.get('/contact', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/contact.html'));
});

app.get('/info', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/info.html'));
});

app.get('/history', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/history.html'));
});

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Czy nie ma jakiejś prostszej drogi? Nie moglibyśmy zdefiniować wszystkiego raz, globalnie?

Owszem, istnieje taka możliwość, ale będzie nam potrzebna funkcjonalność middleware'u. Opowiemy o niej dosłownie za chwilę.

Na razie sprawdź, czy skrypt zaczął ponownie działać. Powinien dać nam następujący efekt:

image

Middleware

Czym jest middleware? Najprościej mówiąc, to funkcjonalność pośrednicząca, która "wpycha się" pomiędzy rozpoczęcie a zakończenie jakiegoś procesu. Gdy poznawaliśmy Reduksa, mówiliśmy o Redux Thunk. On też pełnił właśnie taką rolę pośrednicząca. Wchodził pomiędzy uruchomienie funkcji w komponencie a dispatchowanie akcji i pozwalał na oddalenie tej operacji w czasie (pomagało nam to głównie przy wywołaniach asynchronicznych).

Żeby łatwiej było Ci zrozumieć, o czym mowa, zaczniemy od przykładu.

Powiedzmy, że zbudowaliśmy aplikację sklepu internetowego. Nie jest ona bardzo zaawansowana, ale posiada kilka podstron:

  • /– strona główna, dostępna dla wszystkich.
  • /cart – strona koszyka, również widoczna dla każdego.
  • /admin/products – strona z katalogiem produktów, dostępna tylko dla admina.
  • /admin/payments – strona z rachunkami, również widoczna tylko dla admina.

Pomysł jest oczywiście bardzo prosty. Niektóre podstrony są przygotowane dla klientów, pozostałe służą administrowaniu serwisem i powinny być dostępne tylko dla zalogowanych właścicieli. Jak mógłby wyglądać kod serwera takiej strony?

Wykorzystując naszą dotychczasową wiedzę, na pewno zbudowalibyśmy coś takiego:

const express = require('express');
const path = require('path');

const app = express();

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/home.html'));
});

app.get('/cart', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/cart.html'));
});

app.get('/admin/products', (req, res) => {
  if(isAdmin()) res.sendFile(path.join(__dirname, '/views/admin/products.html'));
  else res.send('Go away!');
});

app.get('/admin/payments', (req, res) => {
  if(isAdmin()) res.sendFile(path.join(__dirname, '/views/admin/payments.html'));
  else res.send('Go away!');
});

...

Oczywiście funkcja isAdmin() jest umowna. Załóżmy, że sprawdza ona, czy użytkownik jest administratorem i zwraca true (jeśli tak) lub false (jeśli nie).

Czy ten kod ma sens? Oczywiście. Widzisz jednak, że podobnie jak w poprzednim rozdziale, mamy problem z powtarzalnością. W każdym endpoincie /admin/... sprawdzamy, czy użytkownik jest administratorem. Nie jest to przeszkodą, póki mamy ich niewiele, ale dla większej liczby mogłoby stać się bardziej uciążliwe.

Nie da się jakoś tego skrócić? W końcu widać, że endpointy "adminowe" mają część wspólną – przedrostek /admin. Nie można by więc w jakiś sposób zmusić Expressa, aby sprawdzał rolę użytkownika dla każdego linku rozpoczynającego się w taki sposób? Odpowiedź jest pozytywna – da się!

Metoda app.use

Wyjściem jest oczywiście wcześniej wspomniany pomysł z middleware. Express dostarcza nam specjalną metodę o nazwie use, która potrafi "wepchnąć się" pomiędzy otrzymane od klienta żądania a zwrócenie odpowiedzi i wykonać jakieś dodatkowe operacje. Co ważne, sami wybieramy, w jakiej sytuacji mają się one wykonywać. Możemy więc przed zwróceniem odpowiedzi pobrać coś z bazy danych albo sprawić, że każde żądanie przeszuka pliki w __dirname. Będziemy mogli również ustawiać operacje tylko dla wybranej grupy endpointów i jest to coś, czego potrzebujemy w przykładzie ze sklepem. Ustalimy, że wszystkie endpointy rozpoczynające się na /admin uruchomią najpierw funkcję sprawdzenia roli, i dopiero gdy potwierdzimy, że użytkownik jest administratorem, middleware uruchomi instrukcję z endpointu.

Podsumujmy więc, jak wyglądałoby to dokładnie w przypadku naszego sklepu.

Obecna sytuacja prezentuje się następująco:

image

Proces jest bardzo prosty. Użytkownik wysyła request, serwer szuka odpowiedniego endpointu i zwraca response.

Jak będzie wyglądał ten proces po zmianach i wprowadzeniu middleware'u?

image

Wciąż po requeście nastąpi response, ale pomiędzy nimi serwer uruchomi nasz middleware, który w założeniu sprawdzi, czy link nie jest przypadkiem jednym z "adminowych". Jeśli tak, wtedy ustali, czy jesteśmy zalogowani i zdecyduje o przekierowaniu nas do endpointu admin/payments lub admin/products, albo po prostu powie "Go away!".

Oczywiście to, co znajdzie się w middleware, zależy tylko i wyłącznie od nas. Na dobrą sprawę, jesteśmy w stanie zrobić tam wszystko. Moglibyśmy nawet ustalić, że nie ważne, jaki link użytkownik wybrał, jego endpoint i tak będzie losowy.

Podsumowując, middleware w Expressie to po prostu pośrednik pomiędzy rozpoczęciem żądania a zwróceniem odpowiedzi klientowi.

Jeśli czujesz, że nie do końca jeszcze rozumiesz cały zamysł, nie przejmuj się. Gdy tylko wykorzystamy middleware w praktyce, przekonasz się, że jest stosunkowo łatwy w użyciu.

Czas na praktykę!

Jak to wszystko wyglądałoby w kodzie?

Metody .use używa się następująco:

app.use(path, (req, res, next) => {
  // some middleware operations
  next();
});

Parametr path to ścieżka, którą chcemy "wychwycić". Ustalenie tu np. wartości /admin informowałoby, że wybieramy requesty zaczynające się właśnie takim przedrostkiem. Gdybyśmy wstawili /, wtedy pod uwagę brane byłyby wszystkie linki (bo w końcu każdy zaczyna się na /). W sytuacji, gdy zależy nam na wszystkich requestach, nie musimy pisać nawet tej wartości. Wystarczy, że zrezygnujemy z tego parametru w ogóle:

app.use((req, res, next) => {
  // some middleware operations
  next();
});

Funkcja callback, którą widzisz dalej, to po prostu instrukcje, które mają się wykonać, jeśli Express uzna, że dany request pasuje (czyli, że np. faktycznie zaczyna się od /admin).

(req, res, next) => {
  // some middleware operations
  next();
});

Pojawiają się tutaj aż trzy parametry. req i res to po prostu obiekt żądania i odpowiedzi. Mamy do nich taki sam dostęp jak w endpointach. To ważna opcja, bo dzięki temu możemy zwrócić coś klientowi już na tym etapie i w ogóle nie uruchamiać kodu spod samego endpointu.

Na przykład:

app.use('/forbidden-area', (req, res, next) => {
  res.send('Go away!');
});

Pozostaje jeszcze parametr next, który – jak widać na przykładzie – jest jakąś funkcją. Jaką dokładnie? Taką, która w normalnych warunkach, bez middleware, byłaby wykonywana pod tym linkiem.

Spójrzmy na taki kod:

app.use('/home', (req, res, next) => {
  console.log('Hello!');
  next();
});

W przypadku linku /home skrypt powoduje pokazanie w konsoli tekstu Hello. Następnie serwer rusza dalej i przeszukuje plik w celu znalezieniu samego konkretnego endpointu.

Co stałoby się, gdybyśmy jednak nie użyli tej funkcji?

app.use('/home', (req, res, next) => {
  console.log('Hello!');
});

W konsoli zobaczylibyśmy tekst Hello i na tym etapie serwer zakończyłby poszukiwania. Nawet gdyby istniał konkretny pasujący endpoint i znajdował się dalej w kodzie, serwer i tak by go nie uruchomił.

Jak widzisz, funkcja next daje nam kontrolę nad dalszym działaniem serwera. To bardzo przydatna opcja.

Aplikacja sklepu po zmianach

Jak wyglądałby kod naszego przykładowego sklepu po zmianach?

const express = require('express');
const path = require('path');

const app = express();

app.use('/admin', (req, res, next) => {
  if(isAdmin()) next();
  else res.send('Go away!');
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/home.html'));
});

app.get('/cart', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/cart.html'));
});

app.get('/admin/products', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/admin/products.html'));
});

app.get('/admin/payments', (req, res) => {
  res.sendFile(path.join(__dirname, '/views/admin/payments.html'));
});

Nas interesuje zwłaszcza ten kawałek:

app.use('/admin', (req, res, next) => {
  if(isAdmin()) next();
  else res.send('Go away!');
});

Jak on działa? Pewnie już się domyślasz. Nasz middleware "wyłapuje" wszystkie żądania, w których link zaczyna się na /admin (czyli np. admin/payments). Następnie, jeśli na taki link trafi, sprawdza, czy jesteśmy administratorem. Jeśli tak, pozwala serwerowi działać dalej i ten spokojnie próbuje znaleźć już konkretny endpoint. Jeśli jednak nie, kończymy odpowiedź, zwracając klientowi tekst Go away!.

Co nam to dało? Teraz w endpointach "adminowych" nie musimy już sprawdzać roli użytkownika. Mamy pewność, że skoro serwer je odpala, wcześniej poprawnie przeszliśmy proces sprawdzenia w middleware.

Powrót do pracy

Wiemy już jak działa middleware, do czego i w jaki sposób możemy wykorzystać use. Czas więc wrócić do naszej aplikacji i spróbować naprawić to, co nam nie pasuje. Chcemy w jakiś sposób zniwelować potrzebę ciągłego odwoływania się do __dirname. Oczywiście pomoże nam w tym middleware.

app.use((req, res, next) => {
  res.show = (name) => {
    res.sendFile(path.join(__dirname, `/views/${name}`));
  };
  next();
});

Spójrz na powyższy kod. Zacznijmy od ustalenia, na jakie requesty zadziała. Skoro nie podano żadnej ścieżki, bierzemy pod uwagę wszystkie linki. Nieważne, czy wejdziemy pod /, contact czy about – na pewno ten middleware się uruchomi.

Co dokładnie się w nim dzieje?

res.show = (name) => {
    res.sendFile(path.join(__dirname, `/views/${name}`));
};

Przede wszystkim dodajemy do obiektu res nową metodę! Ma ona przyjmować nazwę pliku, a następnie zwracać go za pomocą sendFile. Co ważne jednak dobiera się do niego przy użyciu dokładnej ścieżki. Otrzymujemy więc funkcję, która potrzebuje jedynie nazwy pliku, a daje nam to, co wcześniej osiągaliśmy znacznie dłuższym kodem.

Przykładowo res.show('about.html') powinno zwracać klientowi about.html. Jest to znacznie łatwiejszy zapis, a ma taki sam efekt jak res.sendFile(path.join(__dirname, '/views/about.html'));.

Oczywiście next służy do tego, co zawsze – pozwala serwerowi ruszyć dalej w poszukiwaniu endpointu. Co ważne, gdy już go znajdzie, nasza nowa metoda show stanie się dostępna w obiekcie res i będziemy mogli wykorzystać ją zamiast sendFile!

Nasz kod zmieni się więc następująco:

const express = require('express');
const path = require('path');

const app = express();

app.use((req, res, next) => {
  res.show = (name) => {
    res.sendFile(path.join(__dirname, `/views/${name}`));
  };
  next();
});

app.get('/', (req, res) => {
  res.show('index.html');
});

app.get('/about', (req, res) => {
  res.show('about.html');
});

app.get('/contact', (req, res) => {
  res.show('contact.html');
});

app.get('/info', (req, res) => {
  res.show('info.html');
});

app.get('/history', (req, res) => {
  res.show('history.html');
});

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Skąd mamy pewność, że na etapie znalezienia endpointu, funkcja res.show będzie już przygotowana? Nasz middleware jest w kodzie przed endpointami, więc wykona się jako pierwszy. Dodatkowo wyłapujemy wszystkie możliwe linki, zatem nie musimy się bać, że któryś endpoint nie będzie miał dostępu do tej metody.

Wyłapujemy wadliwe linki

Co ciekawe, metoda use może przydać się też do odpowiedniego obsłużenia niepoprawnych requestów! Wystarczy, że wstawimy nasz middleware po endpointach:

...

app.get('/info', (req, res) => {
  res.show('info.html');
});

app.get('/history', (req, res) => {
  res.show('history.html');
});

app.use((req, res) => {
  res.status(404).send('404 not found...');
})

Jak to działa? Jeśli wpiszemy taki link, który rzeczywiście ma pasujący endpoint na serwerze, klient zobaczy właściwą odpowiedź (plik HTML), a serwer skończy na tym etapie proces poszukiwania. Mimo tego, że middleware na końcu nie ma podanej ścieżki i jest w stanie wyłapać każdy request, zwyczajnie nigdy nie zostanie nawet uruchomiony.

Jeśli jednak serwer nie znajdzie pasującego endpointu, to w końcu trafi do middleware i pokaże klientowi odpowiedź 404 not found. Tym samym stworzyliśmy funkcjonalność wyłapującą wszystkie niepoprawne zapytania. Jeśli ktoś wpisze /info, to zwrócimy mu treść pliku info.html, ale jeśli poda link, którego nie obsługujemy – zobaczy przygotowany przez nas komunikat. Na pewno będzie wyglądał on lepiej niż domyślne Cannot GET /.

Kody odpowiedzi

Idea kodów odpowiedzi nie jest Ci zapewne obca. Była już o nich mowa w module dotyczącym AJAX-u i API. Pamiętasz kod 404 zwracany w przypadku braku zasobu lub błędnego linku albo 500 informujący o błędzie serwera? Właśnie o tym mowa.

Kiedy sami programujemy serwer, decydujemy jaki kod odpowiedzi zwrócimy klientowi – będzie to 200 sugerujące, że wszystko gra, czy może coś innego. W przykładzie powyżej zwracamy klientowi kod 404, informujący o błędnej ścieżce. Stąd też użycie wbudowanej w Express funkcji status.

Dlaczego nie ustawialiśmy takiego kodu do wcześniejszych endpointów? Ponieważ tam odpowiedź była jak najbardziej zgodna z oczekiwaniami klienta, a kodem pasującym byłby 200. Jest on domyślną odpowiedzią obiektu response, o ile nie ustalimy inaczej.

Swoją drogą, na pewno udało Ci się zauważyć, że tym razem brakuje funkcji next. Pominęliśmy ją z prostego powodu. Nie ma sensu odpalania kolejnej funkcji, skoro nasz endpoint jest już ostatnim elementem aplikacji.

To trochę inny przykład wykorzystania middleware'u, ale jak widzisz równie przydatny.

Nasza aplikacja powinna wyglądać teraz następująco:

const express = require('express');
const path = require('path');

const app = express();

app.use((req, res, next) => {
  res.show = (name) => {
    res.sendFile(path.join(__dirname, `/views/${name}`));
  };
  next();
});

app.get('/', (req, res) => {
  res.show('index.html');
});

app.get('/about', (req, res) => {
  res.show('about.html');
});

app.get('/contact', (req, res) => {
  res.show('contact.html');
});

app.get('/info', (req, res) => {
  res.show('info.html');
});

app.get('/history', (req, res) => {
  res.show('history.html');
});

app.use((req, res) => {
  res.status(404).send('404 not found...');
})

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

next(), nie tylko w middleware

Dotychczas pokazywaliśmy użycie next wyłącznie przy wykorzystaniu funkcji use. Warto wiedzieć, że tego parametru możemy używać również w samych endpointach.

app.get('/about', (req, res, next) => {
  res.send('About me');
  next();
})

app.use((req, res) => {
  //do something after, disconnect with DB for example
})

Kod powyżej zadziałaby tak, że wejście w link /about nie tylko pokazałoby treść About me, ale kazałoby jeszcze działać serwerowi dalej. Ten szukałby więc kolejnego pasującego endpointu i trafiłby w końcu do... naszego middleware na samym dole.

Jak można zastosować ten pomysł? Moglibyśmy np. na koniec requestu czyścić w ten sposób jakieś dane albo rozłączać się z bazą danych.

Dodajemy zewnętrzne zasoby

Do tej pory nasze podstrony były niezwykle proste. Co się jednak stanie, gdy zaczniemy tworzyć o wiele bardziej skomplikowane witryny, np. z rozbudowanym CSS i wykorzystaniem obrazków? Jak Express poradzi sobie z dużymi plikami HTML? Sprawdźmy!

Do celów testowych edytujemy plik index.html. Obecnie wygląda on tak:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Home</title>
  </head>
  <body>
    <h1>Home</h1>
    <p>Welcome to my page!</p>
  </body>
</html>

Dodamy do niego linkowanie arkusza stylów oraz obrazka.

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Home</title>
    <link rel="stylesheet" href="/style.css">
  </head>
  <body>
    <h1>Home</h1>
    <p>Welcome to my page!</p>
    <img src="test.png">
  </body>
</html>

Nic specjalnego, ale jak widzisz, linkujemy do kolejnych zasobów. Wydaje się, że to jak najbardziej sensowne.

Teraz czas na stworzenie tych plików. Dodaj do folderu projektu style.css z następującą zawartością:

h1 {
  font-weight: lighter;
  font-style: italic;
}

Następnie w tym samym folderze umieść dowolny obrazek o nazwie test.png.

Powinno działać? Zobaczymy! Zrestartuj serwer i sprawdź.

image

Okazuje się, że nie działa ani obrazek, ani arkusz... Sprawdźmy narzędzia developerskie i zakładkę Network.

image

Nasze zasoby nie zostały znalezione, a serwer zwrócił kod błędu 404. Możesz wpisać ścieżkę do pliku bezpośrednio w przeglądarce (http://localhost/style.css), a i tak dostaniesz informację, że ona nie istnieje.

Dlaczego? Przecież wydaje się, że podaliśmy prawidłowe nazwy pliku... Same zasoby też są obecne na dysku w tym samym folderze, w którym uruchamiamy serwer.

Odpowiedź jest prosta. To, że serwer jest uruchamiany w jakimś folderze nie oznacza, że automatycznie daje klientowi dostęp do plików w nim obecnych. Wbrew pozorom, jest to bardzo dobra informacja. Jeśli byłoby inaczej, równie dobrze, jak plik style.css, klient mógłby otworzyć server.js – wystarczyłoby wejść w link http://localhost/server.js. Tego byśmy nie chcieli, bo przecież często są tam informacje poufne, jak na przykład hasła do bazy danych, wykorzystywane przy połączeniu.

Oczywiście serwer ma wgląd do tych plików, jednak domyślnie nie udostępnia ich klientom.

Możemy jednak stworzyć odpowiednie endpointy i ustalić np. że link http://localhost/style.css faktycznie będzie zwracał plik style.css. Tym właśnie zaraz się zajmiemy!

Warto zapamiętać

Utworzenie serwera w danym folderze nie daje klientowi automatycznie dostępu do plików w nim zawartych. Serwer udostępnia tylko takie ścieżki (endpointy), jakie sami w nim podamy.

Przygotowujemy obsługę zasobów

Do dzieła!

W tym momencie obsługujemy następujące endpointy:

  • /
  • /about
  • /contact
  • /info
  • /history

Gdy wpiszemy jakikolwiek inny link, serwer zwróci nam komunikat 404 not found..., o co dba nasz middleware na końcu:

app.use((req, res) => {
  res.status(404).send('404 not found...');
})

Zatem, co dzieje się, kiedy chcemy, aby nasz serwer połączył się z plikiem http://localhost:8000/style.css albo http://localhost:8000/test.png? Otrzymujemy właśnie taki komunikat.

image

Teraz już wiemy, w czym rzecz. Skoro serwer nie może znaleźć endpointu style.css, to trafia do middleware na końcu i zwraca komunikat. Tak samo sytuacja ma się z test.png.

Musimy więc po prostu przygotować te endpointy:

app.get('/style.css', (req, res) => {
  res.sendFile(path.join(__dirname, '/style.css'));
});

app.get('/test.png', (req, res) => {
  res.sendFile(path.join(__dirname, '/test.png'));
});

Od tego momentu, pod /style.css serwer będzie faktycznie zwracał nasz plik style.css, a pod /test.png obrazek test.png. Oczywiście równie dobrze endpoint /style.css mógłby kierować np. do pliku o nazwie main.css. To w końcu my decydujemy o tym, co pokaże się pod danym linkiem.

Wszystko już działa i jak widzisz nie było to takie trudne. Zapewne zdajesz sobie jednak sprawę, że to rozwiązanie bardzo czasochłonne. Często używamy o wiele większej ilości plików zewnętrznych, a ustawianie dla każdego z osobna nowego endpointu nie byłoby najlepszym pomysłem.

Na szczęście wiesz już, że w takich sytuacjach może nam pomóc middleware!

Wbudowany middleware express.static

Co najlepsze, nie musimy nawet pisać go sami. Express daje kilka gotowych funkcji middleware, odpowiadających na najważniejsze potrzeby. Jedną z nich jest właśnie express.static, która pozwala udostępniać całe foldery.

Wystarczyłoby więc dodać taki kod:

app.use(express.static(__dirname));

...a każdy link style.css, test.png, czy nawet package.json, byłby zwracany poprawnie przez serwer! W ten sposób udostępniamy całą zawartość katalogu naszego projektu, bowiem __dirname to jego ścieżka.

Oczywiście nie jest to najlepszym rozwiązaniem, bo oddajemy w ten sposób również dostęp do server.js. Dlatego częściej tworzy się i udostępnia jedynie specjalny katalog na pliki, które będą wykorzystywane przy renderowaniu widoku. Folder ten można nazwać np. public.

app.use(express.static(path.join(__dirname, '/public')));

Jak express.static działa "pod maską"?

Tak naprawdę dość prosto. Jego praca jest równoznaczna z ręcznym dodawaniem endpointów do plików, przy czym nazwa ścieżki jest dokładnie taka sama, jak ścieżka do pliku względem udostępnianego folderu.

Np. dla katalogu public z taką zawartością:

style.css
script.js
/images
  logo.png

app.use(express.static(path.join(__dirname, '/public'))); spowodowałoby obsługę następujących endpointów:

http://localhost:8000/style.css
http://localhost:8000/script.js
http://localhost:8000/images/logo.png

W ramach ćwiczenia spróbuj teraz dokończyć naszą aplikację, a więc:

  1. Stwórz folder public.
  2. Przenieś tam pliki test.png i style.css.
  3. Usuń konkretne endpointy dla tych plików.
  4. Dodaj middleware udostępniający cały katalog public. Oczywiście należy użyć tutaj wbudowanego express.static.

const express = require('express');
const path = require('path');

const app = express();

app.use((req, res, next) => {
  res.show = (name) => {
    res.sendFile(path.join(__dirname, `/views/${name}`));
  };
  next();
});

app.use(express.static(path.join(__dirname, '/public')));

app.get('/', (req, res) => {
  res.show('index.html');
});

app.get('/about', (req, res) => {
  res.show('about.html');
});

app.get('/contact', (req, res) => {
  res.show('contact.html');
});

app.get('/info', (req, res) => {
  res.show('info.html');
});

app.get('/history', (req, res, next) => {
  res.show('history.html');
});

app.use((req, res) => {
  res.status(404).send('404 not found...');
})

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Posumowanie

Nasza aplikacja prawie nie zmieniła się z wyglądu. Nowością jest jedynie pokazywanie własnego komunikatu o błędzie w przypadku wejścia na nieistniejącą stronę i trochę więcej informacji. Modyfikacji dokonaliśmy natomiast "pod maską" – wykorzystaliśmy zewnętrzne pliki do wyświetlania treści, poznaliśmy koncepcję middleware i wykorzystaliśmy ją w praktyce. Mimo nowości skrypt mocno się nie skomplikował. Zwróć uwagę, że nasz serwer to zaledwie 40 linijek kodu.

Zadanie: szlifujemy umiejętności

Jak dotąd, cały kod pisaliśmy razem, a teraz czas na Twoją indywidualną pracę. Co prawda, dawka wiedzy, którą dotychczas poznaliśmy, nie była duża, jednak i tak warto ją sobie utrwalić, zanim przejdziemy dalej.

Twoim zadaniem jest zbudowanie aplikacji/serwera, która będzie działać w następujący sposób:

  1. Wejście pod link / lub home spowoduje pokazanie się treści "Hello world".
  2. Wejście pod link /about spowoduje pokazanie się treści "About me".
  3. Wejście pod link /user/settings oraz /user/panel będzie powodować wyświetlenie informacji o potrzebie logowania. Należy tutaj wykorzystać middleware. Uwaga – nie ma potrzeby tworzenia konkretnych endpointów, w naszym przykładzie i tak nie pozwolimy na zalogowanie ;)
  4. Wejście pod inny link niż wcześniej wspomniane powinno spowodować pokazanie obrazka "404 not found".

Uwagakażda odpowiedź tekstowa ma być zwracana z pliku HTML, podobnie jak robiliśmy to w przykładzie z submodułu. Możesz wykorzystać nasz pomysł z dodawaną w middleware metodą show.

Dodatkowo, gdy link jest nieprawidłowy ("404 not found"), pokazywana strona powinna zwracać obrazek z komunikatem o nieznalezionej witrynie, najlepiej wykorzystać któryś z dostępnych na tej stronie. Co ważne, obrazek ma być ładowany lokalnie.

Możesz wzorować się na projekcie, który przygotowywaliśmy w tym submodule. Powinien być bardzo pomocny.

Jeśli potrzebujesz dokładniejszej instrukcji, możesz ją przeczytać poniżej. Najpierw spróbuj jednak wykonać to zadanie bez jej pomocy.

  1. Stwórz nowy folder projektu. Wygeneruj w nim package.json, pobierz Express i stwórz plik server.js. Następnie wejdź do niego i zacznij edycję.
  2. Rozpocznij od zaimportowania Expressu za pomocą require.
  3. Następnie stwórz nowy serwer (na razie bez endpointów) i ustaw, na jakim porcie będzie nasłuchiwany. Dokładne wytłumaczenie, jak to zrobić, znajduje się w pierwszym submodule.
  4. Zaimportuj path i przygotuj middleware, który zaoferuje nam funkcję do łatwego zwracania plików HTML (wykorzystaj ten, który pojawił się w już submodule).
  5. Stwórz katalog views i dodaj do niego cztery pliki: home.html (z treścią "Hello world"), about.html (z treścią "About me"), forbidden.html (z treścią "You can't be here!") oraz 404.html (z dowolnym obrazkiem o treści "404 not found").
  6. Przygotuj endpointy /, /home i /about. Jako odpowiedź, mają one zwracać właściwe pliki z views.
  7. Stwórz middleware (przed endpointami), który będzie działał na wszystkie requesty, których link zaczyna się na /user/. Powinien on zwracać treść pliku forbidden.html.
  8. Na końcu, po endpointach, dodaj middleware, który będzie wyłapywał niepoprawne linki i zwracał treść pliku 404.html.
  9. Dodaj endpoint do obsługi pliku obrazkowego.

27.3. Komunikacja z użytkownikiem

Na razie budowane przez nas serwery były bardzo statyczne – klient mógł odwiedzić tylko kilka stron i nie miał żadnej możliwości interakcji. W tym module postaramy się to zmienić.

Tak naprawdę ten temat nie jest dla Ciebie zbyt dużą nowością. Podczas kursu używaliśmy serwerów w ten sposób – wykonywaliśmy np. połączenia AJAXowe, które zależnie od parametrów w linku, zwracały Ci odpowiednie dane albo nawet dodawały nowe (requesty z metodą POST). Wtedy jednak nie tworzyliśmy takiego serwera sami, tylko korzystaliśmy z niego jako klient. Teraz postawimy się po drugiej stronie barykady.

Większą nowością może być za to przesyłanie danych bez użycia AJAX-u, w bardziej klasyczny sposób, bezpośrednio z formularzy. Prawdopodobnie częściej w swojej pracy będziesz korzystać z tej pierwszej opcji, warto jednak znać również inne możliwości.

Nodemon – automatyczne odświeżanie zmian

Zanim przejdziemy do dzieła, poznamy narzędzie, które bardzo ułatwi nam pracę. Z frontendu znasz już pomysł automatycznego odświeżania aplikacji po zmianach w plikach źródłowych. Bez tego rozwiązania dotychczasowa praca z serwerem mogła być dla Ciebie niezwykle irytująca, bowiem każda zmiana zmuszała nas do zatrzymania obecnego procesu i uruchomienia serwera od nowa. Na szczęście, tak jak na frontendzie, istnieje narzędzie do automatycznego odświeżania. Nazywa się – Nodemon.

Jego użycie jest dość proste. Wystarczy pobrać i zainstalować globalnie tę paczkę:

yarn global add nodemon

Od tej chwili możemy już uruchamiać nasz serwer z opcją automatycznego odświeżania przy użyciu prostej komendy:

nodemon server.js

Oczywiście, dla innej nazwy pliku komenda brzmiałaby analogicznie (np. nodemon app.js, nodemon index.js).

W ramach ćwiczenia pobierz tę paczkę do projektu, który wykonywaliśmy podczas poprzedniego submodułu, a następnie przetestuj jej działanie. Uwaga – chodzi o projekt opisany w treści rozdziału, a nie Twoje zadanie końcowe.

Przekazujemy proste informacje

Zaczniemy od najprostszej formy komunikacji, a więc przekazywania danych w samym linku.

Jest to coś, co już robiliśmy przy okazji poznawania routingu po stronie klienta. Pamiętasz tak skonstruowane route'y?

<Route path="/posts/:id" exact component={Post} />

Przypomnijmy. :id w powyższym przykładzie jest placeholderem. To, co wpiszemy w linku na jego miejscu, powinno być odbierane przez komponent jako parametr o nazwie id.

Przykładowo, wejście w link /posts/1 wyrenderowałoby komponent Post, z parametrem id o wartości 1.

Analogicznie działa to też w Expressie.

app.get('/post/:id', (req, res) => {
  res.send(`Id postu to ${req.params.id}`);
});

Jedyną różnicą jest to, że podczas pracy z Reactem parametry z route'ów znajdowaliśmy w match.params, a tutaj szukamy ich w obiekcie req.params. To zapewne Cię nie dziwi. Już na początku mówiliśmy, że req to właśnie obiekt requestu (żądania), który będzie dostarczał nam informacje od użytkownika i o użytkowniku. Jeszcze nie raz go wykorzystamy.

Czas na praktykę!

Aby zastosować naszą nową wiedzę w praktyce, wykonamy małe zadanie. Wciąż będziemy rozwijać aplikację budowaną w trakcie poprzedniego submodułu, a naszym celem jest utworzenie nowego endpointu /hello/:name, który po wejściu w ten link będzie witał użytkownika komunikatem Witaj :name!. Zatem np. dla linku /hello/John aplikacja zwróci tekst Witaj John!. Na razie objedziemy się jeszcze bez pliku HTML i zwrócimy zwykły tekst za pomocą res.send.

Spróbuj poradzić sobie bez naszej pomocy. W razie czego, poniżej możesz podejrzeć gotowy kod.

app.get('/hello/:name', (req, res) => {
  res.send(`Hello ${req.params.name}`);
});

Efekt powinien być następujący:

image

Przenosimy treść do pliku HTML

Jak widzisz, poszło nam całkiem łatwo. Do tej pory jednak treść wszystkich podstron umieszczaliśmy w osobnych plikach HTML, dobrze byłoby więc trzymać się tej konwencji. W związku z tym pojawia się pewien problem.

Wszystkie pliki HTML, z których korzystaliśmy, były statyczne – miały swoją zapisaną treść, która nigdy się nie zmieniała. Serwer po prostu je ładował i bez żadnych zmian zwracał klientowi. Sytuacja z naszą nową podstroną jest jednak inna – potrzebujemy plik, którego fragment treści będzie zależny od zmiennej z aplikacji (req.params.name).

Renderowanie template'ów

Wyjściem z sytuacji może być zastosowanie silnika do renderowania template'ów, który pozwoliłby na modyfikację wybranej części pliku HTML, zanim zostanie on zwrócony klientowi. Czy ta idea jest dla Ciebie nowością? Raczej nie, prawda? Taką mniej więcej rolę pełnił np. używany przez nas już w kursie Handlebars.

Pomysł był następujący:

1. W pliku HTML przygotowywaliśmy statyczne template'y. Statyczne, ale jednak z placeholderami, które sugerowały, że przy renderowaniu te informacje będą podmienione na wartość otrzymaną z zewnątrz.

Na przykład:

<script id="template-article-link" type="text/x-handlebars-template">
  <li><a href="#{{ id }}"><span>{{ title }}</span></a></li>
</script>

2. W JS-ie ładowaliśmy wybrany template (lub template'y) i przygotowywaliśmy go do działania za pomocą funkcji compile.

const templates = {
  articleLink: Handlebars.compile(document.querySelector('#template-article-link').innerHTML)
}

3. Następnie bardzo łatwo mogliśmy w JS-ie wygenerować na tej podstawie treść HTML, ustawiając przy tym, jakie mają być wartości placeholderów.

Na przykład:

const linkHTMLData = { id: 1, title: 'Lorem Ipsum' };
const linkHTML = templates.articleLink(linkHTMLData); // outputs: <li><a href="#1"><span>Lorem Ipsum</span></a></li>

Podsumowując – pobieraliśmy template (z placeholderami, np. {{ title }}), podmienialiśmy placeholdery na wybrane wartości (np. {{ title }} na Lorem Ipsum), a na końcu zwracaliśmy zmienioną treść użytkownikowi.

Właśnie takiej funkcjonalności potrzebujemy też w naszej aplikacji – z tą różnicą, że teraz template'em/widokiem będzie cały plik.

Tworzymy HTML, który ma sztywną treść (np. cały <head>), ale też jakieś placeholdery (np. {{ name }}). Gdy użytkownik wejdzie pod nasz endpoint, chcemy, aby Express najpierw podmienił placeholdery na wybrane przez nas wartości (np. {{ name }} na req.params.name), a następnie zwrócił zmienioną treść klientowi. Brzmi podobnie? Tak, to wręcz ten sam pomysł.

Co najlepsze, możemy użyć tego samego narzędzia, a więc właśnie Handlebars! Oczywiście jego implementacja będzie nieco różniła się od tej, którą znamy z frontendu. Do tego Express wspiera używanie silników template'ów, dostarczając nam gotowe wbudowane metody do ich obsługi, zatem nie będzie to bardzo trudne zadanie.

Template'y w praktyce

Zacznijmy od pobrania Handlebars do naszego projektu:

yarn add express-handlebars@3.1.0

Następnie zaimportujmy paczkę w server.js.

const hbs = require('express-handlebars');

Teraz musimy jeszcze odpowiednio zintegrować ją z samym Expressem. Nie jest to trudne, ponieważ Express udostępnia wbudowane metody, które mogą nam w tym pomóc.

app.engine('.hbs', hbs());
app.set('view engine', '.hbs');

Zacznijmy od pierwszej linijki: app.engine pozwala zdefiniować nam, że dane pliki powinny być renderowane przez dany silnik.

app.engine('.hbs', hbs());

W powyższym kodzie informujemy Express o tym, że pliki o rozszerzeniu .hbs powinny być obsługiwane przez silnik hbs (czyli nasz załadowany Handlebars).

app.set('view engine', '.hbs');

Ten fragment mówi, że w aplikacji używamy widoków właśnie o tym rozszerzeniu. Dzięki temu, przy kompilacji, będziemy mogli wskazywać tylko jego nazwę, a Express sam domyśli się, że ma szukać pliku z odpowiednią końcówką.

Zbędna kropka

Jeśli chcesz, możesz pozbyć się kropki przy .hbs.

app.engine('hbs', hbs());
app.set('view engine', 'hbs');

Express domyśla się, że w tym miejscu chcemy przekazywać rozszerzenie, więc jeśli nie wpiszemy kropki, doda ją sobie sam.

Warto wiedzieć, że wywołując hbs(), możemy również odpowiednio skonfigurować nasz silnik, np. definiując ścieżkę, w której będzie szukał widoków/template'ów (domyślnie Handlebars robi to w katalogu views). Na razie nie będziemy jednak tego robić.

Pierwszy template

Przygotuj teraz nowy plik naszego widoku – hello.hbs i umieść go w katalogu views. Nadaj mu następującą treść:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello {{ name }}</h1>
  </body>
</html>

Jak widzisz, większość treści nie będzie się zmieniać, ale mamy też jeden placeholder ({{ name }}). W założeniu to właśnie Handlebars będzie odpowiadał za odpowiednie wyrenderowanie tego pliku. Teraz wystarczy jeszcze zmodyfikować nasz endpoint tak, żeby z niego korzystał.

Pojawi się tutaj nowa wbudowana w Express funkcja – render. Dotychczas zwracaliśmy dane od razu i bez zmian, niezależnie czy był to jakiś tekst (res.send), czy też plik (res.sendFile). Metoda render działa inaczej i zanim zwróci klientowi odpowiedź, przepuści nasz plik przez silnik. Jaki? Ten, który zdefiniowaliśmy wcześniej za pomocą app.set, czyli w naszej sytuacji – Handlebars.

res.render('template-file', placeholderValuesObj);

Funkcja render ma dwa parametry. Pierwszy ustala nazwę widoku, który chcemy wykorzystać, a drugi przekazuje obiekt z wartościami dla placeholderów (oczywiście nie musimy ustawiać tego parametru, jeśli widok nie ma placeholderów).

Czyli na przykład u nas będzie to wyglądało tak:

app.get('/hello/:name', (req, res) => {
  res.render('hello', { name: req.params.name });
});

Taki kod możemy rozumieć jak rozkaz: wczytaj szablon ./views/hello.hbs, podmień placeholder name na req.params.name, a na końcu zwróć już zmienioną treść jako odpowiedź dla klienta.

Oczywiście można przy renderowaniu definiować wartość większej ilości placeholderów, jeśli szablon ich używa. Na przykład:

app.get('/post/:id/:name', (req, res) => {
  res.render('hello', { id: req.params.id, name: req.params.name, date: '21-02-2019' });
});

Wróćmy jednak do naszego przykładu. Skąd silnik wie, że hello ma szukać właśnie w views pod nazwą hello.hbs? Tak jak mówiliśmy już wcześniej, views to domyślny katalog, w którym szukane są szablony (oczywiście można w konfiguracji ustawić inny). Rozszerzenie również jest znane, ponieważ ustawiliśmy je za pomocą app.set.

Zanim sprawdzisz, czy wszystko działa, zróbmy jeszcze jedną rzecz.

W drugim parametrze render, oprócz ustawienia wartości dla placeholderów, możemy wybrać również kilka opcji, które zdefiniują, jak silnik ma zachować się w danej sytuacji. Jeśli tego nie zrobimy, użyje on ustawień domyślnych.

Handlebars korzysta z pomysłu "layoutów" (nie wchodzimy teraz w szczegóły, powiemy o tym za moment), a my na razie chcemy po prostu wyrenderować jeden prosty widok, bez kombinacji. Dlatego wyłączymy tę opcję.

app.get('/hello/:name', (req, res) => {
  res.render('hello', { layout: false, name: req.params.name });
});

Podsumujmy jeszcze raz ten proces.

Wcześniej, kiedy wchodziliśmy pod jakiś endpoint, serwer od razu zwracał nam treść.

image

Teraz, w naszym nowym endpoincie, zanim plik zostanie zwrócony, jest przepuszczony przez silnik Handlebars. Ten najpierw podmienia placehodlery na pożądane przez nas wartości i dopiero zwraca plik.

image

Okej, to już wszystko. Możesz teraz sprawdzić nasz endpoint /hello/:name. Powinien pokazywać różnie powitania, zależnie od wartości parametru name.

image

Jeśli wszystko działa poprawnie, może rozpierać Cię duma. Udało Ci się z sukcesem zaimplementować silnik template'ów.

Nie tylko Handlebars

Oczywiście na rynku jest większy wybór narzędzi do renderowania widoków. Express nie narzuca żadnego z góry. Oprócz Handlebars możemy więc skorzystać m.in. z Mustache.js, Dot.js, czy bardzo popularnego – Pug.

Ten ostatni cechuje się ogromnym uproszczeniem HTML. Nie musimy w nim używać nawet znaków < i >.

doctype html
html(lang='en')
 head
   title Pug
 body
   h1 Pug Examples
   div.container
     p Cool Pug example!

Oczywiście podczas renderowania, Pug konwertuje kod do zwykłego HTML-a.

Unifikacja widoków

Wszystko działa już zgodnie z założeniem, narobiliśmy jednak trochę bałaganu. To niezbyt dobry pomysł, żeby jedne widoki były przechowywane jako pliki .html i obsługiwane przez res.show (czyli tak naprawdę res.sendFile), a inne jako .hbs i obsługiwane przez res.render. Dobrze byłoby zdecydować się na jeden format.

Oczywiście domyślasz się, który wybierzemy – Handlebars. Używanie takiego silnika do zwykłych statycznych plików HTML wydaje się przerostem formy nad treścią, jednak niekoniecznie tak jest.

Po pierwsze, całkiem możliwe, że w przyszłości będziemy chcieli dodać do tych plików jakąś dynamiczną treść. Wtedy, obsługujący je już Handlebars, będzie jak znalazł.

Po drugie, silnik pozwala nam też na importowanie plików oraz budowanie layoutów aplikacji. To na pewno nam się przyda. Będziemy mogli stworzyć jeden główny plik ze stałym headerem i footerem, a pojedyncze widoki będą zawierały już tylko samą treść podstrony. Powiemy jeszcze o tym za chwilę.

Po trzecie, funkcja render nie wymaga ustawiania dokładnej ścieżki do pliku. Zamiast tego, domyślnie szuka ich w views. Dzięki temu moglibyśmy pozbyć się kodu z middlewarem dodającym funkcję .show.

Do roboty!

Zacznij od zmian rozszerzenia plików w katalogu views. Teraz zamiast .html, zawsze będziemy używać .hbs. Oczywiście, nie musimy zmieniać treści w środku. To wciąż mają być te same statyczne pliki.

Następnie usuń middleware dodający funkcję res.show, nie będziemy już go potrzebować. Tak jak mówiliśmy wcześniej – res.render samo domyślnie szuka widoków w views.

W kolejnym kroku podmień we wszystkich endpointach metodę res.show na res.render oraz wskaż nową nazwę pliku. Pamiętaj, że wystarcza sama nazwa (np. index), Express wie już bowiem jakiego rozszerzenia ma szukać. Nie zapomnij dodać też drugiego parametru { layout: false }, na razie nie chcemy używać layoutów. Zatem na przykład res.show('index.html'); zmieni się na res.render('index', { layout: false }).

Jeśli nie masz pewności, czy udało Ci się wykonać powyższe instrukcje poprawnie, poniżej znajduje się gotowy kod.

const express = require('express');
const path = require('path');
const hbs = require('express-handlebars');

const app = express();
app.engine('hbs', hbs());
app.set('view engine', 'hbs');

app.use(express.static(path.join(__dirname, '/public')));

app.get('/', (req, res) => {
  res.render('index', { layout: false });
});

app.get('/hello/:name', (req, res) => {
  res.render('hello', { layout: false, name: req.params.name });
});

app.get('/about', (req, res) => {
  res.render('about', { layout: false });
});

app.get('/contact', (req, res) => {
  res.render('contact', { layout: false });
});

app.get('/info', (req, res) => {
  res.render('info', { layout: false });
});

app.get('/history', (req, res) => {
  res.render('history', { layout: false });
});

app.use((req, res) => {
  res.status(404).send('404 not found...');
})

app.listen(8000, () => {
  console.log('Server is running on port: 8000');
});

Oczywiście końcowy efekt się nie zmienił. Nasza aplikacja wygląda tak samo, jak wcześniej. "Pod maską" udało nam się ją jednak skrócić i wzbogacić o możliwość renderowania różnych odpowiedzi z tych samych widoków (o ile mamy taką potrzebę).

Layouty aplikacji

Bardzo często podstrony witryn mają wiele części wspólnych, na przykład nagłówek z nawigacją albo stopkę. Moglibyśmy w takiej sytuacji po prostu kopiować ten sam kod do każdego z plików, ale co jeśli będziemy chcieli zrobić jakąś modyfikację? Musielibyśmy zmieniać każdą z podstron!

O wiele lepszym pomysłem jest wykorzystanie layoutów dostępnych w Handlebars. Co więcej, twórcy silnika uważają, że ich użycie jest czymś tak naturalnym, że z góry założyli, iż domyślnie powinny być uruchomione. Stąd też, jeśli nie chcieliśmy ich używać, musieliśmy wcześniej skorzystać z opcji { layout: false }.

Layouty pojawiły się już w kursie, chociażby podczas pracy nad Reactem.

Założenie jest następujące. Mamy plik layoutu (Handlebars domyślnie proponuje nazwę main.hbs), który posiada jakąś stałą treść (np. header i footer) oraz jeden placeholder. Ten ostatni rezerwuje miejsce na treść, którą layout otrzyma z zewnątrz.

Może on wyglądać np. tak:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>App</title>
  </head>
  <body>

    <header>
      <h1>It's an awesome app!</h1>
    </header>

    {{{ body }}}

    <footer>
      <p>All rights reserved<p>
    </footer>

  </body>
</html>

{{{ body }}} to właśnie nasz placeholder. Nazwa nie jest przypadkowa – koniecznie musimy użyć tutaj body. Jak pamiętasz z Handlebars, trzy klamerki ({{{) mówią, że placeholder może być treścią HTML. Będzie nią to, co wskaże Handlebars, czyli po prostu widok załadowany zgodnie z tym, co ustawiliśmy przy wywołaniu funkcji render.

Jak to działa w praktyce? Kiedy Handlebars wie, że ma korzystać z szablonu, to tego typu instrukcje nie będą już bezpośrednio renderowane dla klienta:

res.render('home');

Zamiast tego Handlebars załaduje najpierw layout (domyślnie szuka pliku main.handlebars), w miejscu placeholdera wczyta wybrany dokument (tutaj home.hbs) i dopiero taki plik (layout wraz z treścią widoku) zwróci klientowi.

Pokazuje to poniższy schemat.

image

Podsumujmy. Załóżmy, że plik hello.hbs wyglądałby tak:

<h2>Home</h2>

<p>Welcome home!</p>

W podanym przykładzie klient otrzymałby następującą odpowiedź serwera:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>App</title>
  </head>
  <body>

    <header>
      <h1>It's an awesome app!</h1>
    </header>

    <h2>Home</h2>

    <p>Welcome home!</p>

    <footer>
      <p>All rights reserved<p>
    </footer>

  </body>
</html>

Czas na zastosowanie nowej wiedzy w praktyce!

Zadanie: tworzymy layout aplikacji

Twoim zadaniem będzie stworzenie głównego layoutu strony, zawierającego nawigację oraz stopkę. Nie bój się jednak, poprowadzimy Cię za rękę.

1. Zacznij od stworzenia pliku szablonu. Domyślnie Handlebars będzie szukać go w katalogu views\layouts\ pod nazwą main.handlebars, więc należałoby go stworzyć właśnie tam. Jeśli jednak masz ochotę skonfigurować inną ścieżkę oraz inne rozszerzenie, wystarczy zmodyfikować wywołanie hbs() na początku skryptu.

Na przykład taki kod...

app.engine('hbs', hbs({ extname: 'hbs', layoutsDir: './layouts', defaultLayout: 'main' }));

...spowodowałby, że Handlebars chciałby przy renderowaniu korzystać z layoutu main.hbs, dostępnego w folderze layouts.

Następnie dodaj do niego następującą treść:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>App</title>
    <link rel="stylesheet" href="/style.css">
  </head>
  <body>

    <header>
      <h1>My personal page</h1>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href="/contact">Contact</a>
        <a href="/info">Info</a>
        <a href="/history">History</a>
      </nav>
    </header>

    {{{ body }}}

    <footer>
      <p>All rights reserved<p>
    </footer>

  </body>
</html>

2. Zmodyfikuj endpointy, zabierając z wywołania metody render opcję { layout: false }. Od tej pory będziemy już korzystać z layoutów.

Zatem np.

app.get('/about', (req, res) => {
  res.render('about', { layout: false });
});

zmodyfikuj na:

app.get('/about', (req, res) => {
  res.render('about');
});

3. Zmień wszystkie podstrony. Nie muszą już posiadać znaczników head ani body, wystarczy nam teraz sama treść. Reszta będzie dokładana przez layout przy renderowaniu. Przy okazji zmień też poziom nagłówka z h1 na h2.

Na końcu Twoja aplikacja powinna działać następująco:

image

Dla ambitnych

Jeśli chcesz potrenować, mamy dla Ciebie kolejne wyzwanie.

Handlebars pozwala na korzystanie z większej ilości layoutów, dzięki czemu możemy przygotowywać układy stron w kilku wersjach. Na przykład main.js (domyślny layout) mógłby używać jasnego i prostego motywu, a dark.js pokazywałby stronę w ciemniejszych barwach i oferował trochę inny układ headeru.

Który z nich ma być użyty w danej sytuacji, możemy określić bardzo prosto za pomocą opcji layout.

Na przykład:

app.get('/about', (req, res) => {
  res.render('about.hbs', { layout: 'dark' });
});

Twoim zadaniem jest przygotowanie drugiego layoutu dark, w którym kolor tła jest ciemny, tekst jasny, a nazwa strony nie występuje. Następnie należy wykorzystać go dla jednego z endpointów.

27.4. Testowanie połączeń HTTP

Wiemy już, w jaki sposób możemy przekazywać proste informacje serwerowi – wystarczy do tego odpowiednio skonfigurowany endpoint.

Na przykład:

app.get('/hello/:name', (req, res) => {
  res.send(`Hello ${req.params.name}`);
});

Często chcemy jednak przekazywać znacznie bardziej skomplikowane informacje jak np. obiekty czy tablice o sporym rozmiarze. W takiej sytuacji metoda GET już nie podoła.

Istnieje jednak inna metoda, dla której taka rola to chleb powszedni. Mowa o POST. Jak z pewnością pamiętasz z modułu o AJAX i API, pozwala ona na przesyłanie wraz z żądaniem całego body. W tym przypadku body może być prostym obiektem z jednym parametrem, ale i ogromnym, z wieloma rozgałęzieniami. Do tego, możemy za jego pomocą przesyłać nawet pliki!

Nowy endpoint

Przejdźmy od razu do praktyki. Otwórz projekt, który rozwijamy od początku modułu.

Zacznijmy od utworzenia nowego endpointu.

app.post('/contact/send-message', (req, res) => {
  res.json(req.body);
});

Początek jest jasny. Do obsługi endpointu użyliśmy metody .post, zamiast .get. Link /contact/send-message nie jest przypadkowy. Endpoint, który teraz testowo tworzymy, wykorzystamy w dalszej części submodułu.

Metoda res.json nie powinna być dla Ciebie nowością. Co prawda, nie używaliśmy jej w praktyce, ale wspomnieliśmy o niej. To po prostu odpowiednik res.send, jednak służący do zwracania danych w formacie JSON. W tej chwili będzie to konieczny wybór, bo req.body powinien być obiektem. Nie możemy przecież zwrócić obiektu jako tekstu (a do tego służy res.send). Zapewne dziwisz się, że wszystkie endpointy zwracały pliki HTML, a nagle mamy dane w formacie JSON. Spokojnie, to tylko chwilowe działanie i w dalszej części submodułu to się zmieni.

body to po prostu atrybut obiektu zapytania zawierający dane wysyłane wraz z żądaniem. Są one dostępne w obiekcie request, bo tak naprawdę wszystkie informacje o użytkowniku i od użytkownika są zawarte właśnie w nim.

Podsumowując powyższy kod, powinien on zadziałać następująco: po wejściu pod link /contact/send-message (za pomocą metody POST), serwer powinien odczytać wysyłany przez użytkownika body i zwrócić go w formacie JSON. Sprawdźmy, czy rzeczywiście tak jest! Tylko jak?

Postman

Do przetestowania requestu innego niż GET, będziemy potrzebować pomocy. Musimy zmusić przeglądarkę, by w danej sytuacji użyła metody POST, bo akurat takiej potrzebujemy do testu. Podobny problem pojawi się również później, kiedy będziemy chcieli przetestować w praktyce metody DELETE czy PUT.

Na szczęście istnieje program, który to ułatwi. Mowa o Postmanie, czyli aplikacji, która pozwala na łatwe symulowanie wybranych połączeń HTTP. Dzięki niej możemy wykonywać różnorakie requesty z wybranym endpointem i za pomocą dowolnej metody. Jesteśmy również w stanie ustalić jakie dane (body) będą wysyłane wraz z linkiem!

Pobierz ją teraz. Możesz zrobić to tutaj.

Pierwsze uruchomienie

Po pobraniu, od razu uruchom program. Postman wymaga posiadania konta, zatem konieczne będzie jego utworzenie (lub zalogowanie za pomocą Google). Przy kolejnych uruchomieniach, Postman loguje się już automatycznie.

image

Tworzymy kolekcję

Postman pozwala grupować requesty w kolekcje, dzięki czemu możemy łatwo kojarzyć wybrane zapytania z danym projektem. Powrót do nich w przyszłości będzie o wiele łatwiejszy, jeśli zechcemy ponowić dany test. Warto wiedzieć, że raz wykonany request, może być zapisany na stałe w programie i potem wielokrotnie powtarzany.

Stwórz teraz nową kolekcję dla naszego projektu. Aby to zrobić, odnajdź przycisk New Collection w panelu po lewej stronie. Po wybraniu tej opcji Postman pokaże Ci formularz. Musisz nadać kolekcji dowolną nazwę, ewentualnie również opis.

image

Następnie kliknij przycisk Create. Kolekcja powinna zostać utworzona.

image

Obok Twojej kolekcji może znajdować się też folder "Postman Echo". To po prostu zbiór testowych requestów. Nie zaprzątaj sobie nim głowy.

Przyszedł czas na utworzenie pierwszego requestu! Przetestujemy nasz świeżo utworzony endpoint /contact/send-message.

Pierwszy request

Kliknij teraz przycisk New (lewy górny róg), a następnie w otwartym oknie, wybierz "Request".

W formularzu wpisz dowolną nazwę oraz wskaż swoją kolekcję (o ile nie jest ona zaznaczona domyślnie).

image

Następnie kliknij przycisk "Save...". Request został dodany i zapisany, ale musimy jeszcze ustalić, z czym dokładnie się łączymy i w jaki sposób.

Teraz ustawmy odpowiednią metodę. Domyślnie program proponuje GET, a my chcemy testować nasz endpoint używając POST.

Jeśli chodzi o link, wpisz po prostu ścieżkę do naszego endpointu – http://localhost:8000/contact/send-message.

Pozostaje nam tylko ustalenie, jakie body ma być wysłane wraz z linkiem (w końcu do tego służy POST – do przesyłania danych). Możemy zrobić to w zakładce body.

Wybierz "typ" x-www-form-urlencoded i dodaj kilka informacji tekstowych:

  • author o wartości John Doe.
  • sender o wartości johndoe@example.com.
  • title o wartości Lorem Ipsum.
  • message o wartości Hello world!.

Na końcu nasz request powinien wyglądać następująco:

image

x-www-form-url-encoded vs multipart/form-data

Pewnie zastanawiasz się, czym różni się wybrany przez nas typ x-www-form-url-encoded od form-data, również oferowanego przez Postmana. Jeśli sprawdzisz, jak wygląda sposób dodawania danych, to w obu przypadkach jest podobnie – możemy po prostu ustawiać nazwę (key) i wartość (value).

Zatem rola jest faktycznie taka sama – wysłanie listy danych w formacie (klucz -> wartość). Różnica kryje się jednak w sposobie osiągnięcie tego celu.

Gdy przesyłamy dane za pomocą x-www-form-urlencoded, to tak naprawdę są one przekazywane w formie jednego wielkiego stringu. Pojedyncze atrybuty są oddzielane za pomocą &, a znaki inne niż litery i liczby zamieniane na reprezentację kodu ASCII, czyli np. u nas wyglądałby on tak: author=John%20Doe&sender=johndoe%64example%46com&title=Lorem%20Ipsum&message=Hello%20World. Zauważ, że znak spacji czy też kropki, jest reprezentowany aż przez trzy bity. Czasami jest to mało wydajny pomysł, a do tego ciężko wyobrazić sobie wysyłanie w taki sposób plików.

W przypadku form-data pojedyncza informacja jest wysyłana jako osobny fragment. Każdy z nich może mieć swoją wartość, ale też własny content-type (np. text/plain czy text/json), dzięki czemu jesteśmy w stanie wskazać czym tak naprawdę są konkretne dane i w jaki sposób mają zostać obsłużone. Typ form-data jest doskonałym wyborem przy wysyłaniu plików. Poniżej przedstawiamy przykład, jak mógłby wyglądać request:

content-disposition: form-data; name=”title”

value:
Lorem Ipsum

======================

content-disposition: form-data; name=”message”

value:
Hello World

======================

content-disposition: form-data; name=”file_to_upload”; filename=”file.txt”
Content-Type: text/plain

content:
Lorem Ipsum!

Typ form-data nie generuje niepotrzebnie większej ilości bitów, ale za to wymaga od przeglądarki obsługi każdej informacji z osobna. Tym samym w przypadku obsługi prostych informacji będzie mniej wydajny od x-www-form-urlencoded.

Dlatego najlepiej do prostych danych używaj typu x-www-form-urlencoded, a do bardziej skomplikowanych, oraz w sytuacji, kiedy wysyłasz też pliki – form-data.

Czas w końcu go wysłać (przycisk Send) i sprawdzić, co zwróci serwer. Czy zareaguje poprawnie? Nie zapomnij tylko, aby przed wykonywaniem testu go uruchomić!

Bolesne rozczarowanie

Jeśli udało Ci się poprawnie podążać za naszymi wskazówkami, to okno odpowiedzi zwróci Ci pusty tekst... O co chodzi?

image

Możemy przeprowadzić małe śledztwo. Czy wskazaliśmy zły endpoint? Nie, bo przecież w takiej sytuacji nasz serwer zwraca tekst "404 not found". Zresztą kod odpowiedzi też wskazuje sukces (kod 200). Wiemy zatem, że serwer na pewno zatrzymuje się na naszym endpoincie, a jednak nie widzimy treści. Wychodzi więc na to, że puste jest po prostu req.body. Dziwna sprawa, bo przecież body w requeście przekazywaliśmy...

Dlaczego tak jest?

Twoja decyzja

Pamiętasz, gdy podczas nauki Node.js mówiliśmy, że posiada on wiele wbudowanych modułów, ale nie narzuca ich wykorzystania? Były one dostępne, ale żeby ich użyć, należało je zaimportować.

Podobnie sprawy miały się już w samym Expressie z middlewarem express.static – ta funkcjonalność nie była od razu wbudowana w główny proces. Zamiast tego została dostarczona jako middleware, który możemy dodać do naszej aplikacji, ale nie musimy. To sensowne, bo nie zawsze będziemy potrzebować każdej funkcjonalności, a prosty serwer API, który ma służyć tylko jako pośrednik bazy danych, raczej nie potrzebuje express.static.

Tak samo jest z obsługą formularzy. Express nie pomaga na siłę, ale dostarcza middleware, którego możemy użyć, jeśli chcemy je obsługiwać.

express.urlencoded i express.json

Zatem jaki gotowe middleware powinniśmy dołączyć, aby formularz zaczął być poprawnie obsługiwany?

Jeśli chcesz umożliwić obsługę formularzy x-www-form-urlencoded, dodaj middleware express.urlencoded.

app.use(express.urlencoded({ extended: false }));

Jeśli dodatkowo chcesz odbierać dane w formacie JSON (mogą być wysyłane za pomocą form-data), to również express.json:

app.use(express.json());

My oczywiście w naszym przykładzie potrzebujemy tylko pierwszego. Najczęściej jednak będziesz korzystać z obu.

{ extended: false } vs { extended: true }

Co to w ogóle za opcja? Warto wiedzieć, że x-www-form-urlencoded pozwala również na przesyłanie zagnieżdżonych danych, np. person[name]=John&person[age]=25. Opcja extended ustala po prostu, czy chcemy, aby Express odczytywał takie dane faktycznie jako zagnieżdżone czy nie.

Dla wspomnianego przykładu extended: true dałoby nam req.body:

{
  person: {
    name: 'John',
    age: '25' }
}

...natomiast extended: false:

{
  person[name]: 'John',
  person[age]: '25'
}

Oczywiście w naszym requeście dane nie są zagnieżdżone, stąd wybranie opcji extended: false.

Dodaj więc teraz middleware app.use(express.urlencoded({ extended: false })); i ponownie uruchom request w Postmanie. Tym razem powinien dać Ci już następujący rezultat:

image

Brawo! Serwer od teraz poprawnie przyjmuje wysłane dane i zwraca właściwą odpowiedź.

Strona kontaktu

Serwer odbiera już dane, ale wysyłaliśmy je za pomocą Postmana. Teraz zmodyfikujemy witrynę tak, aby było to możliwe także z jej poziomu. Zajmiemy się stroną "Kontakt", bo to ona będzie w założeniu wykorzystywać endpoint /contact/send-message.

Naszym zadaniem jest dodanie niezbyt rozbudowanego formularza kontaktowego z przyciskiem "Wyślij". Użytkownik będzie mógł wpisywać dowolne dane, a gdy kliknie na ten button, formularz powinien zostać wysłany na serwer. Co ważne, nie użyjemy tutaj AJAX-u, ani nie obsłużymy go za pomocą JS-a. Wykorzystamy jedynie serwer, który sprawdzi poprawność danych i jeśli będą w porządku, zwróci użytkownikowi informację, że "Email został wysłany". W przypadku pojawienia się błędów wyświetli się ich lista.

Całość powinna działać mniej więcej tak:

image

Dodajemy formularz

Zaczniemy od najprostszego – dodania na podstronie formularza. Będzie on zawierał cztery pola: "author" (kto się kontaktuje?), "sender" (jaki jest adres email nadawcy?), "title" (tytuł wiadomość) i "message" (treść wiadomości).

Zmodyfikuj więc teraz contact.hbs następująco:

<h2>Contact me</h2>
<form>
  <label>
    Your name: <input type="text" name="author">
  </label>
  <label>
    Your email: <input type="email" name="sender">
  </label>
  <label>
    Title: <input type="text" name="title">
  </label>
   <label>
    Message: <textarea name="message"></textarea>
  </label>
  <button type="submit">Send message</button>
</form>

Czy mamy tutaj coś nowego? Nie używamy atrybutów id, a zamiast nich definiujemy name. Nie bez powodu. Serwer właśnie po name będzie identyfikował dane pole z wysłanego formularza. Pamiętasz, jak w Postmanie ustawialiśmy w body key? Tutaj name w każdym polu to właśnie key (nazwa) danej informacji.

Zauważ, że gdybyśmy chcieli teraz podążać tokiem pracy, jakiego używaliśmy we wcześniejszych modułach, kolejnym etapem byłoby zablokowanie domyślnego zachowania formularza na jego evencie submit, a następnie obsłużenie kliknięcia na button w taki sposób, że wszystko byłoby walidowane w JS-ie.

Tym razem zrobimy jednak inaczej. Nie zablokujemy domyślnego procesu wysyłania formularza, a to dlatego, że teraz mamy już możliwość odebrania go na serwerze. Po raz pierwszy postąpimy więc zgodnie z założeniami twórców tej funkcjonalności.

Klasyczna obsługa formularzy

Musisz uświadomić sobie jedną rzecz: kiedy obsługujesz formularz za pomocą JS-a i nie wysyłasz go na serwer, tak naprawdę działasz wbrew jego założeniom.

Zauważ, że pierwszą rzeczą, którą robiliśmy dotychczas przy pracy z formularzami, było przypinanie funkcji do jego eventu submit i wyłączanie funkcjonalności (event.preventDefault()). Robiliśmy to, aby zablokować domyślne działanie formularzy.

W założeniu jednak kliknięcie "Submit" powinno być równoznaczne z wysłaniem danych na serwer, gdzie będą mogły być należycie obsłużone.

Ta idea to oczywiście pozostałość "starego porządku", a więc czasów, kiedy JS jeszcze raczkował, a od frontendu oczekiwano bardzo niewiele. Całą logiką zajmował się serwer, choć oczywiście było to trochę uciążliwe. W czasach, gdy AJAX jeszcze nie istniał, wykonywanie jakiejkolwiek operacji na stronie było równoznaczne z jej przeładowaniem.

Wiedząc to, łatwo możemy zrozumieć, dlaczego formularze tak ochoczo chcą być przesyłane do serwera. Przecież nawet nie musimy tworzyć specjalnego buttonu o typie submit – kliknięcie na zwykły przycisk z automatu powoduje wysyłkę formularza. Kiedyś było to standardem i nawet nie wyobrażano sobie innej możliwości.

Oczywiście czasy się zmieniły i dziś bardzo często unikamy domyślnego zachowania formularzy. Wolimy całą obsługę zrobić od razu, ewentualnie wysłać żądanie w tle, aby strona nie musiała się niepotrzebnie przeładowywać. Dzięki temu zapewniamy użytkownikowi znacznie lepszy user experience.

Swoją drogą, klasyczny sposób obsługi formularzy jeszcze nie wymarł. W rozbudowanych aplikacjach frontendowych oczywiście raczej go nie uświadczymy, ale bardzo dużo witryn, zwłaszcza tych opartych na PHP, wciąż korzysta z niego na co dzień.

Pozostaje jeszcze jedna sprawa – dokąd tak konkretnie wyśle się ten formularz i za pomocą jakiej metody? Możemy jakoś wskazać, że chodzi właśnie o /contact/send-message?

Domyślnie formularz jest wysyłany pod ten sam link, z którego zainicjowano operację. Jeśli znajduje się na stronie /contact, to trafi właśnie pod ten adres. Domyślną metodą jest GET. Jak się domyślasz, to niezbyt dobra idea do wysyłki danych, bo przecież powodowałaby wysłanie informacji z formularza w... linku (np. contact?author=John%20Doe&sender...).

Na szczęście łatwo możemy zmienić oba ustawienia za pomocą zwykłych atrybutów w elemencie form. Użycie action pozwala zdefiniować link, pod który chcemy wysłać formularz, a method oczywiście metodę. My ustawimy sobie połączenie POST, a sam link określmy jako ./contact/send-message.

<h2>Contact me</h2>
<form action="/contact/send-message" method="POST">
  <label>
  ...

Testujemy

Sprawdźmy teraz, czy wszystko działa. Wejdź na podstronę kontaktu, wpisz do formularza te same dane, które wcześniej wprowadzaliśmy przy testowaniu endpointu w Postmanie i kliknij na przycisk "Send message".

Efekt powinien być dokładnie taki sam, jak w przypadku symulacji w Postmanie. Wysyłamy formularz o takim samym typie (form domyślnie używa x-www-form-urlencoded), z takimi samymi danymi, pod ten sam adres i za pomocą tej samej metody, zatem endpoint powinien otrzymać dokładnie taki sam request.

Faktycznie tak jest:

image

Walidacja

Czas na rozwinięcie roli naszego serwera. W tej chwili tylko odbiera request i zwraca, co otrzymał. W założeniu powinien najpierw zwalidować te dane.

Nie zrobimy tutaj niczego zaawansowanego, po prostu sprawdzimy, czy pola zostały w ogóle wypełnione. Jeśli któreś będzie puste, poinformujemy użytkownika o błędzie, a gdy wszystko okaże się w porządku, zwrócimy komunikat o wysłaniu wiadomości. Oczywiście na tym etapie niczego nie wyślemy i zakończymy nasz projekt na pokazaniu samego komunikatu.

Zmodyfikuj teraz swój endpoint /contact/send-message:

app.post('/contact/send-message', (req, res) => {

  const { author, sender, title, message } = req.body;

  if(author && sender && title && message) {
    res.send('The message has been sent!');
  }
  else {
    res.send('You can\'t leave fields empty!')
  }

});

Nie jest to walidacja najwyższych lotów, ponieważ sprawdzamy tylko, czy pola nie są puste. Nie informujemy nawet, które dokładnie. Dla naszego małego przykładu to jednak wystarczy.

Przetestuj teraz dwukrotnie formularz kontaktu. Za pierwszym razem poprawnie wypełnij wszystkie pola. Serwer powinien zwrócić komunikat "The message has been sent!". Za drugim razem celowo omiń jedno pole i pozostaw je pustym. Zobaczysz wtedy komunikat "You can't leave fields empty!".

Jeśli właśnie tak się stało, walidacja jest gotowa :)

Dopracowujemy odpowiedź

Pokazywanie samych komunikatów tekstowych nie wygląda zbyt profesjonalnie. Zgodnie z założeniami, musimy zwracać plik HTML z formularzem (contact.hbs), oraz nasz komunikat, w zależności od efektów – pozytywny lub negatywny.

Sytuacja jest więc podobna do tej z poprzedniego submodułu, gdzie ostatecznie wspomogliśmy się Handlebars. Mamy bowiem plik, którego jedna część jest stała (formularz), a druga dynamiczna (pojawienie się komunikatu). Skoro Handlebars i tak mamy już przygotowane, spokojnie możemy skorzystać z jego usług w trochę bardziej zaawansowany sposób niż tylko zwracanie statycznego pliku HTML.

Zacznij od modyfikacji contact.hbs. Spróbuj to zrobić bez naszej pomocy. Celem jest dodanie dwóch divów z komunikatami, przy czym powinny być one zależne od sytuacji.

Warunkowanie w Handlebars wygląda następująco:

{{#if yourCondition}}
Your content
{{/if}}

Czyli np.

{{#if isSomethingValid}}
Something is valid!
{{/if}}

Załóż, że u nas zmiennymi warunkowymi będą isError i isSent. Wykorzystamy je przy sprawdzeniu, czy dany komunikat ma się pokazać. Jeśli isSent jest true, to potrzebujemy komunikatu "The message has been sent!", a jeśli isError jest true, to "You can't leave fields empty!".

Spróbuj wykonać tę modyfikację bez naszej pomocy. Oba komunikaty zamknij w divach i nadaj im klasy – .isSent (dla tekstu o sukcesie) i .isError (dla tekstu o porażce).

Następnie wystarczy wykorzystać nasz zmodyfikowany plik przy renderowaniu odpowiedzi w contact/send-message:

if(author && sender && title && message) {
  res.render('contact', { isSent: true });
}
else {
  res.render('contact', { isError: true });
}

Od teraz nasza aplikacja powinna wyglądać następująco:

image

Stylujemy aplikację

Pozostała nam tylko praca nad wyglądem. Nie musisz jednak robić tego na własną rękę. Poniżej znajduje się kod, który wystarczy skopiować do Twojego arkusza style.css.

@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');

body {
  background: #282c34;
  color: #fff;
  font-family: 'Open Sans', sans-serif;
  padding: 30px;
  text-align: center;
}

h1 {
  font-weight: lighter;
}

p {
  color: #999;
  font-size: 0.8rem;
}

a {
  color: #999;
  font-size: 0.8rem;
}

label {
  display: block;
  margin: 1rem;
}

input,
textarea {
  background: rgba(0,0,0,0.2);
  padding: 0.6rem 1rem;
  border: none;
  margin: 0 1rem;
  border-radius: 0.5rem;
  outline: none;
  color: #fff;
  font-size: inherit;
}

textarea {
  margin: 1rem auto;
  width: 400px;
  height: 200px;
  display: block;
}

button {
  background: #0064e7;
  border: none;
  border-radius: 15px;
  padding: 10px 25px;
  color: #fff;
  font-family: inherit;
  margin: 20px 10px;
  text-transform: uppercase;
  outline: none;
  transition: .2s;
  cursor: pointer;
}

.isError {
  background: #fff;
  color: #000;
  border-left: 4px solid #111;
  padding: 0.9rem 0.8rem;
  margin: 1rem auto 1.5rem auto;
  display: flex;
  opacity: 1;
  font-weight: lighter;
  box-shadow: 0 0 0 rgba(0,0,0,0.2);
  max-width: 15rem;
  border-radius: 4px;
  background: #ff5d6c;
  color: #fff;
  border-color:#ea2027;
  -webkit-animation-name: fadeIn;
  animation-name: fadeIn;
  animation-duration: 1s;
}

.isSent {
  background: #fff;
  color: #000;
  border-left: 4px solid #111;
  padding: 0.9rem 0.8rem;
  margin: 1rem auto 1.5rem auto;
  display: flex;
  opacity: 1;
  font-weight: lighter;
  box-shadow: 0 0 0 rgba(0,0,0,0.2);
  max-width: 15rem;
  border-radius: 4px;
  background: #26c281;
  color: #fff;
  border-color:#01916d;
  -webkit-animation-name: fadeIn;
  animation-name: fadeIn;
  animation-duration: 1s;
}

@-webkit-keyframes fadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

I gotowe! :)

Zadanie: rozwijamy podstronę kontaktu

Czas na pracę samodzielną. Twoim zadaniem będzie rozwinięcie formularza kontaktu o jeszcze jedno pole "Project design", które pozwoli wysyłać na serwer plik obrazkowy. Co ważne – wcale nie będziemy przechowywać go na dysku. Ty musisz tylko umożliwić wysłanie wybranego pliku za pomocą formularza, odebrać go na serwerze i zwrócić komunikat, że plik o danej nazwie został "rzekomo" wysłany wraz z samą wiadomością.

Funkcjonalność powinna być zaimplementowana następująco:

  1. Formularz musi rozszerzyć się o jedno nowe pole do wybrania pliku (<input type="file">). Użytkownik powinien być w stanie wybrać dowolny plik, ale tylko jeśli jego rozszerzeniem jest .png, .jpg, .jpeg albo .gif.
  2. Po wysłaniu formularza na serwer, nowe pole powinno być zwalidowane tak samo, jak pozostałe.
  3. Jeśli wszystkie pola zostały wypełnione, użytkownik powinien na końcu otrzymywać ten sam komunikat co wcześniej "The message has been sent!", ale tym razem jeszcze z nową linijką "File nazwapliku has been saved", czyli np. jeśli plik nazywał się test.png, to komunikat wyglądałby tak: "The message has been sent! File test.png has been saved!".

Efekt powinien wyglądać następująco:

image

Jeśli uważasz, że dasz sobie radę bez naszej pomocy, od razu bierz się do pracy. Możliwe jednak, że przydadzą Ci się poniższe wskazówki:

  1. Pamiętaj, że domyślny typ przesyłania formularza (x-www-form-urlencoded) nie jest idealny do transferowania plików, koniecznie ustaw więc multipart/form-data. Możesz to zrobić przy użyciu atrybutu enctype w form.
  2. Aby filtrować pokazywane użytkownikowi pliki (w dialogu systemowym), możesz użyć atrybutu accept.
  3. Nowe pole z plikiem powinno być teraz od zawsze wymagane. Zmiana treści komunikatu sukcesu może być więc "sztywna", musimy jednak wstawić tam również jeden placeholder na nazwę pliku. Potem przy renderowaniu trzeba dynamicznie ustalać jego wartość.

Dla chętnych

Jeśli uważasz, że to dla Ciebie za mało, mamy jeszcze jedno wyzwanie. W tej chwili komunikat o sukcesie pokazuje tylko nazwę pliku, który udało nam się wysłać wraz z formularzem. Spróbuj tak zmodyfikować kod, aby zamiast niego, pokazywał się sam obrazek.

Pomocne będzie faktyczne załadowanie pliku do folderu, w którym jest nasz serwer (najlepiej do public, aby dostęp do niego był od razu ułatwiony). Możesz to zrobić na wiele sposobów, nawet z użyciem gotowych pluginów. Internet będzie tutaj Twoim sprzymierzeńcem.

27.5. REST API w praktyce

Dowiedzieliśmy się już całkiem sporo. Umiemy przy użyciu Expressu tworzyć własne serwery, wiemy jak obsługiwać wybrane endpointy, co znajduje się w obiekcie żądania (request) oraz w jaki sposób zwracać odpowiedzi (response). Dowiedzieliśmy się jak pomocnym narzędziem, również w backendzie, może być silnik do renderowania szablonów. Dodatkowo poznaliśmy sposób klasycznej obsługi formularzy, który teraz nie ma przed nami żadnych tajemnic.

Warto powiedzieć jednak, że w naszej pracy backend niezbyt często będzie pełnił aż tak ważną rolę. Zazwyczaj jego zadanie ograniczy się do pośredniczenia w kontakcie z bazą danych przy użyciu endpointów (mówimy wtedy o Serwerze API) lub/i zwracania gotowej aplikacji pod głównym linkiem.

Taki właśnie serwer zbudujemy teraz w ramach szlifowania naszej wiedzy.

Serwer API, standaryzacja

Czym jest serwer API? To szczególny typ serwera, którego głównym (a najczęściej jedynym) zadaniem jest pośredniczenie w dostępie do jakichś danych. Przeważnie chodzi tutaj o duże rozbudowane bazy np. stworzone przy pomocy MongoDB (ten temat pojawi się jeszcze u nas w kursie). Serwer nie zawsze musi być bardzo zaawansowany. Jego rolą jest po prostu udostępnianie zestawu endpointów, za pomocą których możemy dostać się do danych, a czasem również je modyfikować.

O ile niektóre funkcje, które wykonywaliśmy za pomocą serwera w poprzednich submodułach, mogły być dla Ciebie czymś całkiem nowym, to teraz poczujesz się zapewne jak ryba w wodzie. Prawdopodobnie przed tym modułem właśnie tak postrzegałeś temat serwerów.

Skoro jego rola jest tak mała, dlaczego w ogóle go potrzebujemy? Czy klient nie może bezpośrednio komunikować się z bazą danych? Odpowiedź jest prosta – nie może. Jednak faktycznie, rola serwera często sprowadza się właśnie do tego – pośredniczenia w kontakcie klienta z bazą danych. Gdy na przykład potrzebujemy jakieś dane, prosimy o nie serwer – on wyciąga je z bazy i zwraca. Chcemy dodać nowy rekord? Wysyłamy serwerowi informację, co chcemy dodać, on wprowadza nowe dane do bazy i powiadamia nas o sukcesie albo porażce. Sama komunikacja to "proszenie" o którym teraz pisaliśmy, odbywa się oczywiście poprzez podstawowy protokół komunikacyjny klient-serwer, czyli HTTP. Korzystamy tu z mechanizmu "request-response".

Przykładowy serwer API, udostępniający bazę z użytkownikami, mógłby oferować np. takie endpointy:

  • GET /users – pobiera wszystkie dane z bazy i zwraca je.
  • GET /users/:id – pobiera z bazy użytkownika o id równym parametrowi :id i zwraca go.
  • POST /users – dodaje do bazy nowego użytkownika.
  • DELETE /users/:id – usuwa użytkownika o id równym parametrowi :id.
  • PUT /users/:id – modyfikuje użytkownika o id równym parametrowi :id.

Pobieranie listy użytkowników w aplikacji wyglądałoby tak:

image

Zatem:

  1. Klient (np. za pomocą AJAX-a) wysyła request do serwera pod link /users, używając metody GET.
  2. Serwer odbiera żądanie i łączy się z bazą danych w celu pobrania wszystkich użytkowników.
  3. Po odebraniu danych zwraca je w formie response (odpowiedzi) klientowi.

To właśnie technika, którą w kursie już niejednokrotnie wykorzystywaliśmy, z tą różnicą, że wcześniej część serwerowa była gotowa, a teraz zbudujemy ją sami.

Warto dodać jeszcze jedną rzecz – najczęściej przesyłanie tych danych w jedną i drugą stronę odbywa się przy wykorzystaniu formatu JSON.

REST – podążamy za standardem

We wcześniejszym przykładzie pokazaliśmy, że serwer do kontaktu z bazą danych mógłby udostępniać następujące endpointy:

  • GET /users
  • GET /users/:id
  • POST /users
  • DELETE /users/:id
  • PUT /users/:id

Wykorzystany format może wydawać Ci się intuicyjny i znajomy, ale czy coś stoi na przeszkodzie, aby te same funkcjonalności ukryć np. pod takimi endpointami?

  • GET /get-users
  • GET /get-user-by-id/:id
  • POST /add-user
  • DELETE /delete-user/:id
  • PUT /edit-user/:id

Albo jeszcze innymi, np. /users/getAll, users/byId/:id?

Oczywiście, możemy tak zrobić. To programista decyduje jaki format wykorzysta, a co za tym idzie – w każdym projekcie mógłby zastosować inny. Nie jest to jednak praktyczne. W ten sposób kod mógłby okazać się nieczytelny dla innych developerów, a tworzenie nazw endpointów niekonsekwentne. Dlatego wskazane jest przestrzeganie konwencji i standardów.

REST – Respresentation State Transfer

Prawdopodobnie najpopularniejszym standardem na rynku jest REST. Korzystaliśmy z niego budując endpointy, zarówno w tym, jak i we wcześniejszych modułach.

Konwencja ta określa m.in. sposób komunikacji oraz konstruowania endpointów. W jej zamyśle sam link ma być tylko nazwą kolekcji (i ewentualnie miejscem na parametr), czyli np. users albo users/1. Natomiast to, co powinno być robione z danym zasobem, ma być określane za pomocą metody. Dlatego nawet ten sam link, przy połączeniu z inną metodą, może powodować inną zmianę w bazie danych – np. GET /posts zwróci nam liczbę postów, a DELETE /posts usunie je.

Przykładowa aplikacja

Aby wykorzystać naszą wiedzę w praktyce, zbudujemy teraz własny serwer API. Co ważne, skonstruujemy tylko część backendową, a do testowania endpointów użyjemy Postmana.

Naszym zadaniem będzie zbudowanie serwera API do obsługi księgi klientów firmy, który udostępni następujące endpointy:

  • GET /testimonials – zwracanie całej listy wpisów.
  • GET /testimonials/:id – zwracanie konkretnego wpisu.
  • GET /testimonials/random – zwracanie losowego wpisu.
  • POST /testimonials – dodawanie nowego wpisu na bazie req.body, otrzymanego od części frontendowej.
  • PUT /testimonials/:id – modyfikacja wpisu o danym id na bazie req.body otrzymanego od części frontendowej.
  • DELETE /testimonials/:id – usunięcie wpisu o konkretnym id.

Uwaga

Nasza pomoc w tym module będzie już bardzo ograniczona. Wszystko, czego potrzebujesz, było już wcześniej przedstawione. Naprowadzimy Cię na rozwiązanie, ale nie pojawi się wiele snippetów. Potraktuj to jako powtórkę zdobytej wiedzy.

Tworzymy szkielet

Zacznijmy od stworzenia szkieletu serwera. Utwórz nowy folder projektu, pobierz Express, następnie dodaj plik server.js i dopisz kod odpowiadający za tworzenie serwera na dowolnym porcie (np. 8000). Na razie nie dodawaj jeszcze żadnych endpointów ani middleware. Możesz również zainstalować Nodemon, aby nie musieć po każdej zmianie odświeżać serwera.

Jeśli masz z tym problem, zajrzyj do submodułów powyżej, gdzie robiliśmy to razem.

Dodajemy podstawowy middleware

Czas na dodanie middleware. Oczywiście możesz stwierdzić, że nie wiemy jeszcze jak dokładnie będą wyglądać nasze endpointy. Po kilku projektach łatwo jednak stwierdzić, że jest już stały zestaw middleware, które są swego rodzaju standardem. Oczywiście, jeśli zajdzie potrzeba, zawsze możemy dołożyć kolejne później.

Jak myślisz, które z poznanych przez Ciebie wbudowanych middleware Expressu, przydadzą się nam w tym przykładzie? Pamiętaj, że zakładamy budowę wyłącznie serwera API, nie będziemy renderować żadnego widoku, a jedynie zwracać dane lub komunikaty w formacie JSON.

Podpowiemy, że dotychczas poznaliśmy następujące middleware:

  • express.static
  • express.urlencoded
  • express.json

Z powyższych, tylko express.static będzie w naszym przypadku zbędny. Nie musimy renderować żadnego skomplikowanego widoku ani nie będziemy korzystać z CSS-a lub plików zewnętrznych.

Dołącz potrzebne middleware do Twojej aplikacji. Pamiętaj, że powinny być one umieszczone w kodzie dość wysoko, najlepiej po utworzeniu serwera, a koniecznie przez samymi konkretnymi endpointami, które wkrótce dodamy.

Publiczne API a CORS

W tym momencie warto powiedzieć o jeszcze jednym użytecznym middleware – CORS. Nie jest on wbudowany w Express, ale przydaje się podobnie jak express.json czy express.static. Nie w każdym serwerze będziemy musieli z niego skorzystać, ale warto poznać go na przyszłość.

Mechanizm CORS zapewne nie jest Ci całkiem obcy. Został on zaimplementowany przez wszystkie nowoczesne przeglądarki i stoi za funkcjonalnością wykonywania żądań AJAX-owych. Jedną z jego ważnych opcji jest również możliwość ograniczania połączeń. Możemy np. ustawić, że nasze API pozwala na połączenie tylko i wyłączne z konkretnej domeny oraz z konkretnych metod, albo – co istotne dla publicznych API – z każdej domeny.

Niestety sama konfiguracja CORS jest dość kłopotliwa, więc do gry musi wejść paczka middleware cors. Pozwala ona w łatwy sposób odblokować wszystkie połączenia.

app.use(cors());

Można też sprecyzować inne ustawienia, na przykład:

app.use(cors({
  "origin": "https://kodilla.com", //origin sets domains that we approve
  "methods": "GET,POST", //we allow only GET and POST methods
}));

Zastanów się, czy będziemy potrzebować tej paczki w naszym API?

Dodajemy "bazę danych"

Aby przechowywać referencje od klientów firmy, potrzebna będzie nam jakaś baza danych. Oczywiście, na tym etapie kursu jeszcze ich nie omawialiśmy, więc w naszym przypadku tą bazą będzie tylko zwykła tablica. Naturalnie w większym projekcie byłoby to strasznie niewygodne, a same dane ginęłyby przy restarcie serwera, ale do naszych potrzeb na razie wystarczy.

Stwórz więc teraz nową tablicę db i umieść w niej kilka rekordów w poniższym formacie:

const db = [
  { id: 1, author: 'John Doe', text: 'This company is worth every coin!' },
  { id: 2, author: 'Amanda Doe', text: 'They really know how to make you happy.' },
];

Możesz nawet skopiować dokładnie te same dane.

Czas na endpointy

Teraz czas na najważniejszą część zadania, czyli dodanie wszystkich potrzebnych endpointów.

  • GET /testimonials – ma po prostu zwracać całą zawartość tablicy.
  • GET /testimonials/:id – zwracamy tylko jeden element tablicy, zgodny z :id.
  • GET /testimonials/random – zwracamy losowy element z tablicy.
  • POST /testimonials – dodajemy nowy element do tablicy. Możesz założyć, że body przekazywane przez klienta będzie obiektem z dwoma atrybutami author i text. Id dodawanego elementu musisz losować.
  • PUT /testimonials/:id – modyfikujemy atrybuty author i text elementu tablicy o pasującym :id. Załóż, że body otrzymane w requeście będzie obiektem z atrybutami author i text.
  • DELETE /testimonials/:id – usuwamy z tablicy wpis o podanym id.

Dodatkowo:

  • Wszystkie dane mają być zwracane w formacie JSON. Express dostarcza gotową metodę w obiekcie response, która Ci w tym pomoże.
  • W przypadku endpointów dla POST, PUT czy DELETE zwracaj po prostu obiekt { message: 'OK' }.
  • Do losowania id możesz użyć zewnętrznej biblioteki, np. uuid.
  • Walidacja nie jest wymagana, ale jeśli masz ochotę, możesz również sprawdzać, czy wszystkie dane zostały przekazane i jeśli nie, zwracać komunikat z błędem – koniecznie jednak w formacie JSON. Nie zapomnij w takiej sytuacji o ustawieniu odpowiedniego kodu odpowiedzi.
  • Do testowania endpointów użyj Postmana, który pozwoli Ci upewnić się, czy wszystkie działają zgodnie z planem.

Upewnij się

Zakładamy, że Twój kod w endpoincie prawdopodobnie nie będzie sprawdzał, czy operacja modyfikacji albo usunięcia się udała, tylko zawsze zwróci { message: 'OK' }. Nie jest to może idealne rozwiązanie, ale w naszym prostym przykładzie tak to będzie wyglądać. Aby mieć pewność, że endpointy POST, DELETE i PUT działają poprawnie, możesz po ich wykonaniu ponownie odpalać GET /testimonials, który pokaże Ci, czy faktycznie doszło do zmian w tablicy.

Wyłapujemy wadliwe linki

Na końcu, po wszystkich endpointach, dodaj jeszcze jeden middleware. Taki, który będzie wyłapywał wszystkie wadliwe linki i zwracał obiekt JSON { message: 'Not found...' } z kodem odpowiedzi 404.

Gotowe!

Jeśli wszystko poszło dobrze, endpointy powinny działać następująco:

image

Dobre praktyki – grupowanie endpointów

Dopóki liczba endpointów nie jest duża, spokojnie możemy trzymać wszystkie w jednym pliku – nawet w tym samym, w którym tworzymy serwer. Gdy ich ilość wzrasta, wtedy warto zastanowić się nad wydzieleniem grup endpointów do osobnych plików. Nie jest to zbyt trudne, zwłaszcza że Express oferuje wsparcie takiej idei out of the box.

Możemy tworzyć zewnętrzne pliki, które przechowują same endpointy przy użyciu funkcjonalności express.Router.

Na przykład:

// post.routes.js

const express = require('express');
const router = express.Router();
const db = require('./../db');

// get all posts
router.route('/posts').get((req, res) => {
  res.json(db.posts);
});

/* ... */

module.exports = router;

Importujemy Express, przygotowujemy pusty router i ściągamy tablicę z danymi (w tym przykładzie jest ona zawarta w osobnym pliku db.js). Następnie dodajemy do routera endpointy i go eksportujemy.

Oczywiście takich plików z routerami możemy trzymać wiele, np. jeden do przechowywania endpointów obsługujących dane users, drugi do comments, a trzeci jeszcze innych.

Żeby korzystać z nich już na samym serwerze, musimy je po prostu zaimportować i dostarczyć (za pomocą app.use).

Na przykład:

/* ... */
const app = express();

// import routes
const postRoutes = require('./routes/post.routes');
const usersRoutes = require('./routes/users.routes');

app.use(cors());
app.use(express.urlencoded({ extended: true}));
app.use(express.json());
app.use('/api', postRoutes); // add post routes to server
app.use('/api', usersRoutes); // add user routes to server

/* ... */

Ustawienie ścieżki path w middleware sprawia, że nasze endpointy (np. /posts czy /users) będą dostępne tylko po wejściu na nie z wybranym przedrostkiem (/api/posts, /api/users). Oczywiście, jeśli chcesz, aby Twoje endpointy były dostępne bezpośrednio, możesz ustawić podstawowy path /.

Takie wydzielanie route'ów jest bardzo pożyteczne. W ten sposób zmniejszamy ilość kodu w głównym dokumencie, a dzięki umieszczeniu endpointów w osobnych plikach, łatwiej możemy je potem odnaleźć i modyfikować. Przydaje się to zwłaszcza, kiedy będziemy mówić o bardzo dużym API i sporej liczbie endpointów.

Jeszcze jedna sprawa – fakt, że pliki umieściliśmy w folderze routes, a ich rozszerzenia to routes.js (np. users.routes.js), nie jest niczym obligatoryjnym. To tylko konwencja, której możesz się trzymać, ale nie musisz. Równie dobrze pliki z endpointami mogłyby mieć końcówkę end.js, a folder nazywać się /endpoints.

Nie zawsze musimy też wydzielać endpointy. Czasami, przy małych aplikacjach, nie będzie to potrzebne. Dotychczas w module tego nie robiliśmy. Warto jednak wiedzieć, że istnieje taka możliwość i jest całkiem łatwa w implementacji.

Zadanie: grupujemy endpointy

Nasz serwer API oferuje na razie zestaw endpointów do obsługi danych testimonials. Teraz dodamy kolejne dwie kolekcje danych i nowe endpointy do ich obsługi.

Etap 1 – Modyfikujemy "bazę danych"

Zacznij od modyfikacji tablicy z danymi.

  1. Po pierwsze wyciągnij ją do zewnętrznego pliku db.js.
  2. Następnie zmień jej strukturę – z tablicy na obiekt, przy czym obecne dane powinny być tablicą dostępną pod atrybutem testimonials tego obiektu. Do tego przygotuj dwa kolejne atrybuty (również tablice) o nazwach concerts i seats.
  3. Jako zawartość concerts i seats posłuży poniższy kod.

db.concerts

[
  { id: 1, performer: 'John Doe', genre: 'Rock', price: 25, day: 1, image: '/img/uploads/1fsd324fsdg.jpg' },
  { id: 2, performer: 'Rebekah Parker', genre: 'R&B', price: 25, day: 1, image: '/img/uploads/2f342s4fsdg.jpg' },
  { id: 3, performer: 'Maybell Haley', genre: 'Pop', price: 40, day: 1, image: '/img/uploads/hdfh42sd213.jpg' },
]

db.seats

[
  { id: 1, day: 1, seat: 3, client: 'Amanda Doe', email: 'amandadoe@example.com' },
  { id: 2, day: 1, seat: 9, client: 'Curtis Johnson', email: 'curtisj@example.com'  },
  { id: 3, day: 1, seat: 10, client: 'Felix McManara', email: 'felxim98@example.com'  },
  { id: 4, day: 1, seat: 26, client: 'Fauna Keithrins', email: 'mefauna312@example.com'  },
  { id: 5, day: 2, seat: 1, client: 'Felix McManara', email: 'felxim98@example.com'  },
  { id: 6, day: 2, seat: 2, client: 'Molier Lo Celso', email: 'moiler.lo.celso@example.com'  },
]
  1. Wyeksportuj ten obiekt i zaimportuj go do pliku, w którym aktualnie przechowujesz endpointy.

Uwaga

Zauważ, że do importu i eksportu nie używamy na serwerze frontowego import ... export, tylko require. Jeśli nie pamiętasz z poprzedniego modułu, jak to wyglądało, potraktuj przykład z wydzielaniem endpointów z tego submodułu jako ściągawkę.

  1. Nie zapomnij jeszcze zmienić dostępu do bazy w endpointach /testimonials. Teraz kolekcja, do której się odwołujemy to db.testimionals, a nie db.

Etap 2 – Nowe endpointy

Czas na dodanie nowych endpointów do obsługi concerts i seats. Potrzebujemy następujących:

  • GET /concerts
  • GET /concerts/:id
  • POST /concerts
  • DELETE /concerts/:id
  • PUT /concerts/:id
  • GET /seats
  • GET /seats/:id
  • POST /seats
  • DELETE /seats/:id
  • PUT /seats/id

Ich działanie ma być analogiczne do endpointów z grupy testimonials, które już wcześniej przygotowano, czyli np. GET /concerts powinno zwracać listę wszystkich koncertów.

Etap 3 – Wydzielamy endpointy

Zgodnie ze wskazówkami z ostatniego rozdziału submodułu, wydziel wszystkie trzy grupy endpointów testimonials, concerts i seats do zewnętrznych plików (testimonials.routes.js, concerts.routes.js i seats.routes.js), a najlepiej również do nowego folderu (np. routes). Następnie zaimportuj je do pliku server.js.

Uwaga

W przykładzie endpointy były dołączane w taki sposób, aby działały tylko z przedrostkiem /api. Teraz także zastosujemy się do tej konwencji. Pamiętaj jednak, że w takim przypadku nie będą już dostępne pod linkami typu /testimonials czy /testimionals/:id, a wyłącznie pod /api/testimonials oraz /api/testimonials:id.

Etap 4 – Małe ułatwienie

Na końcu dodaj do package.json skrypt start, który będzie skrótem do uruchomienia komendy nodemon server.js.

Gotowe

Sprawdź za pomocą Postmana, czy endpointy działają poprawnie. Jeśli tak, wszystko gotowe!

Dla ambitnych

Walidacja danych nie jest wymagana, ale jeśli masz ochotę, możesz również sprawdzać, czy wszystkie dane są przekazywane do każdego z endpointów zgodnie z założeniami. Jeśli nie, serwer powinien podać komunikat z błędem, koniecznie w formacie JSON. Nie zapomnij w takiej sytuacji również o ustawieniu odpowiedniego kodu odpowiedzi.

27.6. Publikujemy aplikację

Pisząc nasz serwer API, tak naprawdę nie wiedzieliśmy, do czego będzie on wykorzystywany. Dostaliśmy po prostu założenia, których musieliśmy się trzymać. To nic nadzwyczajnego, bardzo często w pracy mierzymy się z taką sytuacją – nie znamy szczegółów całego projektu i wykonujemy jakąś część zadań, bazując na otrzymanej specyfikacji.

Naszą rolą była tylko implementacja serwera API, a część frontedowa została powierzona innemu developerowi. Przygotował on cały front, wraz z jego integracją z backendem (po prostu wykorzystał nasz gotowy serwer API). Niestety nie zdążył opublikować aplikacji i wprowadzić kilku zmian, o które klient dodatkowo poprosił. Naszym zadaniem będzie dokończenie tych zadań.

Parę słów o projekcie

Będziemy pracować nad witryną festiwalu muzycznego "New Wave Festival", która ma tylko trzy podstrony:

  1. Home – mamy tu karuzelę oraz informacje o występujących artystach (dane pobrane z API).
  2. Prices – podstrona z informacją o cenach.
  3. Order a ticket – możemy tutaj zamówić bilet na wybrany koncert. Podstrona wykorzystuje informację z serwera o ilości wolnych miejsc. Dzięki temu użytkownik może zarezerwować tylko dostępne miejscówki.

Nas interesuje tylko trzecia podstrona. To tutaj będziemy wprowadzać wszystkie modyfikacje.

Lista zadań

Co mamy do zrobienia?

Etap 1

W tej chwili aplikacja pobiera listę miejsc tylko raz – po wejściu na podstronę "Order a ticket". Problem w tym, że w czasie, gdy wpisujemy dane i wybieramy miejsce, może ono zostać zajęte przez innego użytkownika. Powinniśmy częściej odświeżać te informacje.

Etap 2

Drugie zadanie łączy się z pierwszym. Nawet jeśli informacje będą odświeżały się co dwie minuty i tak nie mamy pewności, że w tym czasie ktoś nie zajął miejsca. Musimy więc zmodyfikować serwer API, żeby odpowiedni endpoint sprawdzał przy bukowaniu, czy miejsce jest wolne i jeśli nie, zwracał informację o błędzie.

Publikacja

Kiedy wszystko będzie już gotowe, musimy opublikować aplikację na Heroku.

Bierzemy się do pracy

Zanim zaczniemy pracować nad samymi zadaniami, musisz pobrać materiały frontendowe. Możesz to zrobić pod tym linkiem. Po pobraniu umieść całość w nowym katalogu client w Twojej aplikacji z serwerem.

Developer użył Reacta i Reduksa oraz wykorzystał React Router, do tego bazował na szablonie startowym Create React App, więc wszystko powinno wyglądać dla Ciebie znajomo.

Po pobraniu frontu aplikacji zainstaluj pakiety zdefiniowane w package.json za pomocą komendy yarn (lub yarn install – oba są odpowiednikiem npm install), a następnie uruchom. Wystartuj również proces serwera.

Uwaga!

Podgląd aplikacji reactowej otwiera się pod adresem localhost:3000, a więc port jest inny niż ten używany przez serwer. Skoro adresy są różne, koniecznie należy odpowiednio skonfigurować serwer – musi pozwalać na połączenia z "obcych" serwerów. Jeśli więc wcześniej na Twoim serwerze API nie zastosowano middleware cors, czas to zmienić.

Etap 1

Zaczniemy od podstrony "Order a ticket" do zamawiania biletów. Posiada ona funkcjonalność wyboru miejsca i pokazuje aktualny stan wypełnienia sali.

image

Niestety informacje o zajętych miejscach ładowane są tylko raz, po wejściu na podstronę. Jeśli użytkownik decyduje się dość długo, możliwe, że w tym czasie liczba i układ wolnych miejsc się zmieni.

Za odświeżanie danych o zajętych miejscach (w SeatChooser) odpowiada metoda loadSeats, dostępna w propsach (dispatchuje akcję w Reduksie). W tej chwili jest ona odpalana tylko raz, w bloku componentDidMount komponentu seatChooser. Twoim zadaniem jest dodanie funkcjonalności uruchamiania tej metody również po zarezerwowaniu biletu (jeśli wypełniono wszystkie pola) oraz cyklicznie – co dwie minuty.

Uwaga!

Zauważ, że bilet jest fizycznie zarezerwowany dopiero po wykonaniu asynchronicznego requestu na serwerze.

Nawet jeśli odpalisz loadSeats bezpośrednio po metodzie addSeat, obie uruchomią się asynchronicznie. Istnieje zatem prawdopodobieństwo, że to właśnie loadSeats wykona się jako pierwsza, mimo tego, że w kodzie występuje jako druga.

W takiej sytuacji możemy wspomóc się mechanizmem async-await. Zauważ, że funkcja submitForm ma już nawet przedrostek async, czyli spokojnie możemy użyć wewnątrz niej await, przed wywołaniem addSeat. Dzięki temu zmusimy JSa, by poczekał do momentu, aż request addSeat się wykona.

Spróbuj zrobić to zadanie bez naszej pomocy.

  1. Pamiętaj, że funkcja, która sprawdza, czy dane są poprawne i rezerwuje bilet, to już część komponentu rodzica – orderTicketForm. loadSeats nie jest jednak indywidualną funkcją SeatChooser, tylko akcją z reducera. Możesz mieć więc do niej dostęp również w innym komponencie – orderTicketForm, który ma już nawet przygotowany container. Wystarczy więc zmapować odpowiednią akcję, aby móc jej użyć również w nim.
  2. Do cyklicznego wywoływania wybranej funkcji możesz użyć nawet zwykłego setInterval. Nie zapomnij jednak o wyczyszczeniu tego interwału, gdy komponent jest odmontowywany (componentWillUnmount).

Etap 2

W tej chwili Twój endpoint POST /seats nie sprawdza, czy dane miejsce na koncercie było już zarezerwowane. Ewentualnie, jeśli zostało wykonane zadanie dla ambitnych, walidujesz tylko, czy otrzymujemy prawidłowe dane.

Nie możemy liczyć na aplikację klienta, że to ona zablokuje możliwość zajęcia zarezerwowanego miejsca. Wiesz już bowiem, że nawet po naszych zmianach, dane nie są w stu procentach zsynchronizowane z tym, co znajduje się w "bazie danych". Do tego część kliencka zawsze jest podatna na manipulacje – każdy może wejść do konsoli i za pomocą narzędzi developerskich narobić wielu szkód, np. wyłączyć walidację. Nawet jeśli byłaby optymalna, to i tak musielibyśmy, na wszelki wypadek, sprawdzić dostępność miejsca jeszcze raz, na serwerze.

To będzie nasze zadanie. Musimy tak zmodyfikować serwer, by w przypadku, gdy wybrane miejsce jest zajęte, zwrócił komunikat o treści: { message: "The slot is already taken..." }. Koniecznie ustaw też odpowiedni kod statusu.

Spróbuj wykonać to zadanie bez naszej pomocy.

Uwaga!

Pamiętaj, że mamy trzy koncerty (trzy dni). Sprawdzaj więc nie tylko, czy dany seat już się pojawił, ale konkretnie – czy jest on zajęty na wybrany dzień (koncert).

Możesz skorzystać z pomocy wbudowanej funkcji some, która sprawdza, czy dany element chociaż raz pojawia się w tablicy. Jeśli tak, zwraca true. Więcej informacji znajdziesz pod tym linkiem.

Zadanie: publikacja na Heroku

Przed Tobą ostatni etap, czyli opublikowanie aplikacji na Heroku.

Nowa rola serwera

Najpierw musimy poczynić jednak małe zmiany.

W tej chwili nasza aplikacja działa na dwóch serwerach lokalnych. Jeden operuje na porcie 8000 i obsługuje nasze połączenia do API, drugi jest uruchamiany na porcie 3000 i zajmuje się prezentowaniem podglądu części frontendowej. Podczas prac developerskich zwyczajnie nie mamy innej opcji – nie możemy wykorzystać serwera podglądu Webpacka również jako serwera API.

Problem w tym, że Heroku nie przyjmie takiej konfiguracji. Serwis ten pozwala na hostowanie tylko kompletnych stron, które są utrzymywane na jednym serwerze.

Musimy więc nieco zmodyfikować naszą aplikację. Jak sobie z tym poradzimy?

W dość łatwy sposób – po prostu zrezygnujemy przy publikacji z jednego serwera. Którego? Oczywiście z Webpacka, bo potrzebujemy go tylko na etapie developerskim. Gdy wygenerujemy gotową aplikację (za pomocą komendy yarn build), otrzymamy folder dist, zawierający zwykły plik HTML, oraz dodatkowo – pliki ze zbundlowanymi skryptami i stylami. Do ich odpalenia nie będzie nam już potrzebny webpack-dev-server, a zatem serwer Express bez trudu przejmie rolę pokazywania zbudowanego projektu.

Przygotowanie do budowy

Zanim zbudujemy naszą aplikację, warto wprowadzić jeszcze kilka zmian. Odnajdź w kliencie pliki subreducerów i usuń wszystkie niepotrzebne setTimeouty. Służyły one tylko do symulacji długiego ładowania danych przez serwer zdalny. Nie będzie nam to już potrzebne.

Przy okazji, nie zapomnij zmienić ścieżki do serwera API w config.js. Nie powinniśmy już kierować się zawsze do localhostu. Zamiast tego, najlepiej ustawić, aby ścieżka była zależna od środowiska. Jeśli process.env jest produkcyjny, to powinniśmy szukać endpointów od razu na tym samym serwerze, wystarczy więc tylko ścieżka /api. W środowisku developerskim nadal powinien być to stary adres, bo do lokalnych prac będziemy potrzebować webpackowego podglądu Reacta, a dopiero z niego połączymy się z serwerem API.

export const API_URL = (process.env.NODE_ENV === 'production') ? '/api' : 'http://localhost:8000/api';

Budujemy naszą aplikację

Wejdź do konsoli w folderze client i zbuduj aplikację za pomocą komendy:

yarn build

Nasza gotowa aplikacja powinna być dostępna w folderze build. Jest to już dystrybucja odpowiednio przygotowana przez Webpacka, którą możemy uruchomić bez żadnych dodatkowych skryptów. To właśnie ona stanie się widoczna po zmianach, gdy użytkownik wejdzie na nasz serwer Express. Będziemy serwować ją pod endpointem /.

Modyfikacja serwera

Aby było to możliwe, musimy zmodyfikować nasz serwer. Zacznijmy od dodania nowego endpointu, który będzie zwracał naszą aplikację.

Wejdź do pliku server.js i pod middleware z naszymi grupami endpointów dodaj następujący kod:

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '/client/build/index.html'));
});

Koniecznie musi on znajdować się pod middleware, które dodawały nasze endpointy z API. Kod będzie starał się wyłapywać wszystkie możliwe linki, więc gdyby znalazł się przed naszymi endpointami z zewnętrznych plików, przechwytywałby również ich adresy (np. /seats czy /concerts).

Od teraz wejście w każdy link, którego serwer nie dopasował do wcześniejszych endpointów, będzie powodować po prostu zwrócenie w przeglądarce naszej reactowej aplikacji.

Musimy przygotować jeszcze obsługę pozostałych plików z client/build, takich jak np. bundle skryptów JS-owych i CSS.

// Serve static files from the React app
app.use(express.static(path.join(__dirname, '/client/build')));

Uwaga!

Żeby korzystać z path, musimy najpierw zaimportować ten moduł.

Jak widzisz, ostatecznie skorzystamy również z wbudowanego middleware express.static :)

Na koniec warto zmienić port serwera. Zamiast sztywnej wartości powinniśmy pozwalać na pobieranie jej ze zmiennych konfiguracyjnych Heroku.

app.listen(process.env.PORT || 8000, () => {
  console.log('Server is running on port: 8000');
});

Teraz lokalnie wciąż będziemy korzystali z portu 8000, ale Heroku na repo zdalnym będzie wykorzystywać informacje zapisane w konfiguracji.

Zrestartuj aplikację serwera. Od teraz po wejściu w link http://localhost:8000 powinniśmy widzieć naszą reactową aplikację! Oznacza to, że zarówno serwer, jak i klient, znajdują się od tego momentu w jednym miejscu. Po prostu pod endpointami /api/... mamy teraz funkcjonalności serwera API (a więc dostęp do danych), a pod endpointem głównym / serwujemy aplikację klienta.

Przygotowujemy serwer Heroku

Nasza aplikacja jest już gotowa do publikacji, musimy jeszcze przygotować nasz zdalny host.

Zacznij od zalogowania się na stronie https://www.heroku.com/.

W panelu wybierz New.

image

Następnie kliknij opcję Create New App.

Heroku spyta teraz o podstawowe ustawienia dla nowej aplikacji:

image

Nazwa aplikacji może być dowolna. Jako region wybierz Europę.

Następnie kliknij w przycisk Create App. Nasz serwer jest od tego momentu gotowy! Ale... jest też pusty. Musimy przesłać do niego aplikację, która czeka na naszym komputerze.

Publikujemy aplikację na Heroku

Aby opublikować aplikację na Heroku, możemy skorzystać z trzech opcji:

  1. Wykorzystać Heroku CLI.
  2. Połączyć nasze repo GIT z Heroku.
  3. Wykorzystać dockerowy container.

My skorzystamy z pierwszej, która jest najbardziej intuicyjna dla osób zaznajomionych z Gitem.

Będziemy do tego potrzebować dodatkowego narzędzia – Heroku CLI. Jeśli jeszcze go nie masz, możesz je pobrać pod tym linkiem.

Zacznijmy od zalogowania się do Heroku. Wejdź do konsoli w katalogu domowym naszej aplikacji i uruchom komendę:

heroku login

Po zalogowaniu zainicjuj repo git (jeśli Twój projekt jeszcze nim nie jest). W kolejnym kroku utworzymy połączenie ze zdalnym repo, które stworzyliśmy wcześniej na Heroku:

heroku git:remote -a app-name

Na przykład:

heroku git:remote -a calm-ravine-17050

Nazwę aplikacji możesz przypomnieć sobie, zaglądając do panelu administracyjnego w Heroku.

Czas na małą modyfikację naszego package.json w głównym katalogu. Chcielibyśmy, aby Heroku przy każdym deployu aplikacji budowało od nowa aplikację reactową w folderze build. Oczywiście możemy robić to ręcznie przed każdym pushem, ale wygodniej będzie to zautomatyzować.

Dodaj więc nowy skrypt "build", który będzie mówił, co powinno dziać się wraz z deployem:

"build": "cd client && yarn install && yarn build"

Następnie wyślij w commicie wszystkie zmiany i wypchnij repo na nasz serwer zdalny:

git push heroku master

Uwaga

Przed commitem warto utworzyć .gitignore, ignorujący katalog /node_modules, aby nie pushować niepotrzebnie tego folderu na repo Heroku.

Gotowe!

Możesz sprawdzić, czy wszystko poszło dobrze, korzystając z komendy heroku open. Przekieruję Cię ona od razu pod adres Twojej strony.

Link do działającej aplikacji umieść w pliku Readme.md na Twoim repo na GitHubie. Następnie wyślij adres do repozytorium swojemu mentorowi.

27.7. Podsumowanie

W tym module krok po kroku powiększaliśmy wachlarz naszych umiejętności, rozpoczynając od budowy prostego serwera, a kończąc na kompletnym serwerze API oraz jego integracji z klientem. W końcu możesz nazwać się fullstackowym developerem! :)

Sama koncepcja serwera oczywiście nie była Ci obca przed tym modułem. Teraz jednak udało nam się dotknąć tego tematu bezpośrednio i przyznaj – nie było aż tak źle. Duża w tym zasługa Expressu, który sporo rzeczy nam ułatwił. Dobrze go zapamiętaj, bo jeszcze nie raz skorzystasz z jego pomocy.

27.8. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on utrwalić wiedzę z poprzednich modułów.

Odpowiedzi nie są nigdzie zapisywane, pozostają tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

1. Kod JavaScript uruchamiany za pomocą Node.js:

Wyjaśnienie

Node.js to osobny silnik JavaScriptu, który nie jest zawarty w przeglądarce. Do korzystania z Node.js nie musimy w ogóle mieć zainstalowanej przeglądarki.

Daje on nam prawie wszystkie możliwości, które znamy z JS-a pisanego dla przeglądarki. Tak samo używamy zmiennych, pętli, etc. Podobnie jak w przeglądarkowym JS-ie możemy korzystać z importów i eksportów (tyle że za pomocą require) i łączyć się z serwerami (np. API).

W samym Node.js nie znajdziemy za to obiektów window i document – skoro nie ma przeglądarki, to nie ma też jej okna, ani dokumentu w tym oknie. Te obiekty mogą jednak być dodane przez jakiś pakiet NPM – mieliśmy okazję spotkać się z nimi przy okazji korzystania z Electrona.

Jednocześnie, Node.js daje nam dużo szersze możliwości w zakresie dostępu do plików na dysku komputera, na którym został uruchomiony, a także komunikację z użytkownikiem za pomocą terminala lub GUI stworzonego np. za pomocą Electrona.

2. Możemy używać Node.js do:

Wyjaśnienie

Wszystkie powyższe odpowiedzi są poprawne – wyliczają główne zastosowania aplikacji uruchamianych w Node.js. O ile na początku skupiliśmy się na zastosowaniach nie-webowych, aby lepiej poznać możliwości Node.js, przez resztę kursu zajmiemy się głównie wykorzystaniem Node.js w zakresie backendu, czyli aplikacji uruchamianej na serwerze, odpowiedzialnej za serwowanie naszych projektów oraz pełniącej funkcję API.

3. Możemy publikować własne pakiety w repozytorium NPM, dzięki czemu:

Wyjaśnienie

Głównym celem publikacji modułów w repozytorium NPM jest umożliwienie ich instalacji za pomocą komend npm install ... lub yarn add .... Może to być przydatne zarówno dla innych developerów, jak i dla nas samych w sytuacjach, kiedy potrzebujemy pewną funkcjonalność załączać w wielu projektach.

Warto jednak wspomnieć, że pakiety nie muszą być publicznie dostępne. NPM umożliwia założenie płatnego konta lub nawet utrzymywanie prywatnego serwera z pakietami. W obu podejściach trzeba jednak liczyć się z kosztami, więc na tym etapie nauki nie warto korzystać z tych rozwiązań.

Pamiętaj też, że kod dostępny w repozytorium NPM nie jest w żaden sposób sprawdzany. Jeśli popełnisz błąd, pakiet zostanie z nim opublikowany. Co więcej, nowa (poprawiona) wersja paczki nie zostanie automatycznie zainstalowana w projektach, które z niej korzystają.

Wyjątkiem jest sytuacja, gdy nie zmieni się główny (major) numer wersji, czyli np. zmienisz 1.9.3 na 1.9.4, a potem sklonujesz na nowo repozytorium i uruchomisz npm install (lub yarn albo yarn install). Wtedy może być zainstalowana nowa wersja, ale jest to sytuacja brzegowa. W większości wypadków aktualizacja pakietu będzie wymagała ręcznej podmiany wersji wykorzystywanej w danym projekcie.

;